int, 'lines' => string[] ] */ public static function fit_title( string $font_path, string $text, int $max_width, int $start_size, int $min_size, int $max_lines ): array { for ( $size = $start_size; $size >= $min_size; $size -= 4 ) { $lines = self::wrap( $font_path, $size, $text, $max_width ); if ( count( $lines ) > $max_lines ) { continue; } $fits = true; foreach ( $lines as $line ) { if ( self::text_width( $font_path, $size, $line ) > $max_width ) { $fits = false; break; } } if ( $fits ) { return [ 'size' => $size, 'lines' => $lines, ]; } } return [ 'size' => $min_size, 'lines' => array_slice( self::wrap( $font_path, $min_size, $text, $max_width ), 0, $max_lines ), ]; } /** * Break text into lines that fit a width. * * A single word longer than the width gets its own line and overflows. * * @param string $font_path Path to a .ttf file. * @param int $size Font size. * @param string $text Text to wrap. * @param int $max_width Maximum line width. * @return array */ private static function wrap( string $font_path, int $size, string $text, int $max_width ): array { $words = preg_split( '/\s+/u', trim( $text ) ); $words = is_array( $words ) ? array_filter( $words, 'strlen' ) : []; $lines = []; $current = ''; foreach ( $words as $word ) { $candidate = '' === $current ? $word : $current . ' ' . $word; if ( '' === $current || self::text_width( $font_path, $size, $candidate ) <= $max_width ) { $current = $candidate; continue; } $lines[] = $current; $current = $word; } if ( '' !== $current ) { $lines[] = $current; } return $lines; } /** * Measure the pixel width of a piece of text. * * @param string $font_path Path to a .ttf file. * @param int $size Font size. * @param string $text Text to measure. * @return int */ private static function text_width( string $font_path, int $size, string $text ): int { $box = imagettfbbox( $size, 0, $font_path, $text ); if ( ! $box ) { return PHP_INT_MAX; } return (int) abs( $box[2] - $box[0] ); } /** * Write the title, date and place onto the canvas. * * When $title is null the date takes the title's place, so a title the * bundled font cannot draw never turns into empty boxes. * * @param \GdImage $image Image resource. * @param int $width Canvas width. * @param int $height Canvas height. * @param string|null $title Title to draw, or null to skip it. * @param string $date Date line. * @param string $place Place line. * @return void */ private static function draw_text( \GdImage $image, int $width, int $height, ?string $title, string $date, string $place ): void { $bold = self::font_path( 'bold' ); $regular = self::font_path( 'regular' ); if ( ! file_exists( $bold ) || ! file_exists( $regular ) ) { return; } $white = imagecolorallocate( $image, 255, 255, 255 ); $muted = imagecolorallocate( $image, 226, 232, 240 ); $padding = (int) round( $width * 0.07 ); $box = $width - ( $padding * 2 ); // When there is no drawable title, the date becomes the headline. $headline = null !== $title && '' !== trim( $title ) ? $title : $date; $sub_date = $headline === $date ? '' : $date; if ( '' === trim( $headline ) ) { return; } $fit = self::fit_title( $bold, $headline, $box, (int) round( $height * 0.17 ), (int) round( $height * 0.06 ), 2 ); $title_lead = (int) round( $fit['size'] * 1.25 ); $meta_lines = array_values( array_filter( [ $sub_date, $place ], 'strlen' ) ); // Tie the small text to the shorter side too, so a tall, narrow card // does not push the date line past the right edge. $meta_size = self::fit_meta_size( $regular, $meta_lines, $box, (int) round( min( $width * 0.032, $height * 0.055 ) ) ); $meta_lead = (int) round( $meta_size * 1.6 ); $meta_gap = (int) round( $height * 0.035 ); $bar_height = max( 4, (int) round( $height * 0.012 ) ); $bar_gap = (int) round( $height * 0.05 ); $block = $bar_height + $bar_gap + ( count( $fit['lines'] ) * $title_lead ); if ( ! empty( $meta_lines ) ) { $block += $meta_gap + ( count( $meta_lines ) * $meta_lead ); } $y = (int) round( ( $height - $block ) / 2 ); imagefilledrectangle( $image, $padding, $y, $padding + (int) round( $width * 0.08 ), $y + $bar_height, $white ); $y += $bar_height + $bar_gap + $fit['size']; foreach ( $fit['lines'] as $line ) { imagettftext( $image, $fit['size'], 0, $padding, $y, $white, $bold, $line ); $y += $title_lead; } // Back up to the last title baseline, then start the small text below it. $y = $y - $title_lead + $meta_gap + $meta_lead; foreach ( $meta_lines as $line ) { imagettftext( $image, $meta_size, 0, $padding, $y, $muted, $regular, $line ); $y += $meta_lead; } } /** * Shrink the small text until every line fits the box. * * @param string $font_path Path to a .ttf file. * @param array $lines Lines to measure. * @param int $max_width Maximum line width in pixels. * @param int $start_size Size to start from. * @return int */ private static function fit_meta_size( string $font_path, array $lines, int $max_width, int $start_size ): int { $min_size = max( 10, (int) round( $start_size * 0.6 ) ); for ( $size = $start_size; $size > $min_size; $size -= 2 ) { $fits = true; foreach ( $lines as $line ) { if ( self::text_width( $font_path, $size, $line ) > $max_width ) { $fits = false; break; } } if ( $fits ) { return $size; } } return $min_size; } /** * Fill the canvas with a top-to-bottom gradient. * * @param \GdImage $image Image resource. * @param int $width Canvas width. * @param int $height Canvas height. * @param array $start Top colour as [ r, g, b ]. * @param array $end Bottom colour as [ r, g, b ]. * @return void */ private static function draw_gradient( \GdImage $image, int $width, int $height, array $start, array $end ): void { $span = $height > 1 ? $height - 1 : 1; for ( $y = 0; $y < $height; $y++ ) { $ratio = $y / $span; $color = imagecolorallocate( $image, (int) round( $start[0] + ( $end[0] - $start[0] ) * $ratio ), (int) round( $start[1] + ( $end[1] - $start[1] ) * $ratio ), (int) round( $start[2] + ( $end[2] - $start[2] ) * $ratio ) ); imagefilledrectangle( $image, 0, $y, $width, $y, $color ); } } /** * Build an unused temp file path ending in .png. * * @param string $dir Directory to write into. * @return string */ private static function temp_path( string $dir ): string { $dir = '' !== trim( $dir ) ? rtrim( $dir, '/\\' ) : sys_get_temp_dir(); return $dir . '/aisentic-banner-' . bin2hex( random_bytes( 8 ) ) . '.png'; } }