From 71a063dac311cb21639801fbae6af7c5106c2699 Mon Sep 17 00:00:00 2001
From: Yassine Doghri <yassine@doghri.fr>
Date: Mon, 6 Dec 2021 16:27:00 +0000
Subject: [PATCH] feat(video-clips): allow episodeNumbering text to stand in
 the indent of episodeTitle paragraph

---
 .../MediaClipper/Config/MediaClipper.php      |  65 +++---
 app/Libraries/MediaClipper/VideoClip.php      | 216 ++++++++++++------
 2 files changed, 182 insertions(+), 99 deletions(-)

diff --git a/app/Libraries/MediaClipper/Config/MediaClipper.php b/app/Libraries/MediaClipper/Config/MediaClipper.php
index 5f5420d6ef..52702da64b 100644
--- a/app/Libraries/MediaClipper/Config/MediaClipper.php
+++ b/app/Libraries/MediaClipper/Config/MediaClipper.php
@@ -15,7 +15,7 @@ class MediaClipper extends BaseConfig
     public string $wavesMask = APPPATH . 'Libraries/MediaClipper/waves-mask.png';
 
     /**
-     * @var array<string, array<string, int|array<string, int|string>>>
+     * @var array<string, array<string, int|array<string, float|int|string>>>
      */
     public array $formats = [
         'landscape' => [
@@ -34,25 +34,25 @@ class MediaClipper extends BaseConfig
                 'x' => 810,
                 'y' => 210,
             ],
+            'podcastTitle' => [
+                'fontsize' => 20,
+                'x' => 150,
+                'y' => 620,
+                'lineWidth' => 510,
+            ],
             'episodeTitle' => [
                 'fontsize' => 32,
                 'x' => 150,
                 'y' => 660,
                 'lines' => 3,
-                'lineWidth' => 28,
-                'leading' => 20,
-            ],
-            'podcastTitle' => [
-                'fontsize' => 20,
-                'x' => 150,
-                'y' => 620,
+                'lineWidth' => 510,
+                'lineHeight' => 1.5,
             ],
             'episodeNumbering' => [
                 'fontsize' => 18,
                 'paddingX' => 10,
                 'paddingY' => 5,
-                'x' => 180,
-                'y' => 540,
+                'marginRight' => 10,
             ],
             'timestamp' => [
                 'fontsize' => 32,
@@ -95,25 +95,25 @@ class MediaClipper extends BaseConfig
                 'x' => 75,
                 'y' => 520,
             ],
+            'podcastTitle' => [
+                'fontsize' => 32,
+                'x' => 360,
+                'y' => 55,
+                'lineWidth' => 670,
+            ],
             'episodeTitle' => [
                 'fontsize' => 42,
                 'x' => 360,
                 'y' => 110,
                 'lines' => 3,
-                'lineWidth' => 32,
-                'leading' => 20,
-            ],
-            'podcastTitle' => [
-                'fontsize' => 32,
-                'x' => 360,
-                'y' => 55,
+                'lineWidth' => 670,
+                'lineHeight' => 1.5,
             ],
             'episodeNumbering' => [
                 'fontsize' => 28,
-                'paddingX' => 0,
+                'paddingX' => 10,
                 'paddingY' => 10,
-                'x' => 50,
-                'y' => 330,
+                'marginRight' => 10,
             ],
             'timestamp' => [
                 'fontsize' => 48,
@@ -156,25 +156,26 @@ class MediaClipper extends BaseConfig
                 'x' => 85,
                 'y' => 320,
             ],
+            'podcastTitle' => [
+                'fontsize' => 28,
+                'x' => 260,
+                'y' => 50,
+                'lines' => 1,
+                'lineWidth' => 700,
+            ],
             'episodeTitle' => [
                 'fontsize' => 36,
                 'x' => 260,
                 'y' => 90,
                 'lines' => 2,
-                'lineWidth' => 38,
-                'leading' => 20,
-            ],
-            'podcastTitle' => [
-                'fontsize' => 28,
-                'x' => 260,
-                'y' => 50,
+                'lineWidth' => 700,
+                'lineHeight' => 1.5,
             ],
             'episodeNumbering' => [
-                'fontsize' => 20,
-                'paddingX' => 0,
-                'paddingY' => 10,
-                'x' => 40,
-                'y' => 240,
+                'fontsize' => 24,
+                'paddingX' => 10,
+                'paddingY' => 5,
+                'marginRight' => 10,
             ],
             'timestamp' => [
                 'fontsize' => 48,
diff --git a/app/Libraries/MediaClipper/VideoClip.php b/app/Libraries/MediaClipper/VideoClip.php
index e2f7ad6e21..9be8e42f37 100644
--- a/app/Libraries/MediaClipper/VideoClip.php
+++ b/app/Libraries/MediaClipper/VideoClip.php
@@ -45,7 +45,7 @@ class VideoClip
     protected ?string $episodeNumbering = null;
 
     /**
-     * @var array<string, int|array<string, int|string>>
+     * @var array<string, mixed>
      */
     protected array $dimensions = [];
 
@@ -224,38 +224,63 @@ class VideoClip
             return false;
         }
 
-        $this->addTextToImage(
-            $background,
-            $this->dimensions['episodeTitle']['x'],
-            $this->dimensions['episodeTitle']['y'],
-            $this->episode->title,
-            $this->getFont('episodeTitle'),
-            $this->dimensions['episodeTitle']['fontsize'],
-            $this->dimensions['episodeTitle']['lines'],
-            $this->dimensions['episodeTitle']['lineWidth'],
-            $this->dimensions['episodeTitle']['leading'],
-        );
-        $this->addTextToImage(
+        $this->addParagraphToImage(
             $background,
             $this->dimensions['podcastTitle']['x'],
             $this->dimensions['podcastTitle']['y'],
             $this->episode->podcast->title,
             $this->getFont('podcastTitle'),
-            $this->dimensions['podcastTitle']['fontsize']
+            $this->dimensions['podcastTitle']['fontsize'],
+            $this->dimensions['podcastTitle']['lineWidth'],
+            $this->dimensions['podcastTitle']['lines'] ?? 1,
+            $this->dimensions['podcastTitle']['lineHeight'] ?? 1,
         );
+
+        $episodeNumberingWidth = 0;
         if ($this->episodeNumbering) {
+            $episodeTitleBox = $this->calculateTextBox(
+                $this->dimensions['episodeTitle']['fontsize'],
+                0,
+                $this->getFont('episodeTitle'),
+                $this->episode->title
+            );
+            $episodeNumberingBox = $this->calculateTextBox(
+                $this->dimensions['episodeNumbering']['fontsize'],
+                0,
+                $this->getFont('episodeNumbering'),
+                $this->episodeNumbering
+            );
+            if (! $episodeTitleBox || ! $episodeNumberingBox) {
+                return false;
+            }
+
+            $episodeTitleCenter = (int) ($episodeTitleBox['height'] / 2);
+            $episodeNumberingCenter = (int) (($episodeNumberingBox['height'] + ($this->dimensions['episodeNumbering']['paddingY'] * 2)) / 2);
+            $episodeNumberingWidth = $episodeNumberingBox['width'] + ($this->dimensions['episodeNumbering']['paddingX'] * 2);
+
             $this->addTextWithBox(
                 $background,
-                $this->dimensions['episodeNumbering']['x'],
-                $this->dimensions['episodeNumbering']['y'],
+                $this->dimensions['episodeTitle']['x'],
+                $this->dimensions['episodeTitle']['y'] + $episodeTitleCenter - $episodeNumberingCenter,
                 $this->episodeNumbering,
                 $this->getFont('episodeNumbering'),
                 $this->dimensions['episodeNumbering']['fontsize'],
                 $this->dimensions['episodeNumbering']['paddingX'],
                 $this->dimensions['episodeNumbering']['paddingY'],
             );
-            // dd($this->episodeNumbering);
         }
+        $this->addParagraphToImage(
+            $background,
+            $this->dimensions['episodeTitle']['x'],
+            $this->dimensions['episodeTitle']['y'],
+            $this->episode->title,
+            $this->getFont('episodeTitle'),
+            $this->dimensions['episodeTitle']['fontsize'],
+            $this->dimensions['episodeTitle']['lineWidth'],
+            $this->dimensions['episodeTitle']['lines'],
+            $this->dimensions['episodeTitle']['lineHeight'] ?? 1,
+            $episodeNumberingWidth + ($episodeNumberingWidth === 0 ? 0 : $this->dimensions['episodeNumbering']['marginRight']),
+        );
 
         // Add quotes for subtitles
         $quotes = imagecreatefrompng(config('MediaClipper')->quotesImage);
@@ -412,16 +437,17 @@ class VideoClip
         return imagecopy($background, $foreground, $x, $y, 0, 0, $width, $height);
     }
 
-    private function addTextToImage(
+    private function addParagraphToImage(
         GdImage $image,
         int $x,
         int $y,
         string $text,
         string $fontPath,
         int $fontsize,
+        int $lineWidth,
         int $numberOfLines = 1,
-        int $lineWidth = 32,
-        int $leading = 5,
+        float $lineHeight = 1,
+        int $paragraphIndent = 0,
     ): bool {
         // Allocate A Color For The Text
         $white = imagecolorallocate($image, 255, 255, 255);
@@ -430,35 +456,60 @@ class VideoClip
             return false;
         }
 
-        if ($numberOfLines > 1) {
-            $text = wordwrap($text, $lineWidth, PHP_EOL);
-            preg_match_all('~' . PHP_EOL . '~', $text, $matches, PREG_OFFSET_CAPTURE);
-            if (array_key_exists($numberOfLines - 1, $matches[0])) {
-                $text = substr($text, 0, (int) $matches[0][$numberOfLines - 1][1]) . '…';
-            }
+        $lines = $this->textToParagraph($text, $fontPath, $fontsize, $lineWidth, $numberOfLines, $paragraphIndent);
+        if (! $lines) {
+            return false;
+        }
 
-            $lines = explode(PHP_EOL, $text);
-            foreach ($lines as $i => $line) {
-                // Print line On Image
-                imagettftext(
-                    $image,
-                    $fontsize,
-                    0,
-                    $x,
-                    $y + $fontsize + (($fontsize + $leading) * $i),
-                    $white,
-                    $fontPath,
-                    $line
-                );
-            }
-        } else {
-            // Print Text On Image
-            imagettftext($image, $fontsize, 0, $x, $y + $fontsize, $white, $fontPath, $text);
+        $leading = (int) ($fontsize * $lineHeight);
+        foreach ($lines as $i => $line) {
+            // Print line On Image
+            imagettftext(
+                $image,
+                $fontsize,
+                0,
+                $x + ($paragraphIndent * ($i === 0 ? 1 : 0)),
+                $y + $fontsize + ($leading * $i),
+                $white,
+                $fontPath,
+                $line
+            );
         }
 
         return true;
     }
 
+    /**
+     * Adapted from: https://www.php.net/manual/fr/function.imagettfbbox.php#105593
+     *
+     * @return array<string, mixed>|false
+     */
+    private function calculateTextBox(int $fontSize, int $fontAngle, string $fontFile, string $text): array | false
+    {
+        /************
+        simple function that calculates the *exact* bounding box (single pixel precision).
+        The function returns an associative array with these keys:
+        left, top:  coordinates you will pass to imagettftext
+        width, height: dimension of the image you have to create
+        *************/
+        $bbox = imagettfbbox($fontSize, $fontAngle, $fontFile, $text);
+        if (! $bbox) {
+            return false;
+        }
+        $minX = min([$bbox[0], $bbox[2], $bbox[4], $bbox[6]]);
+        $maxX = max([$bbox[0], $bbox[2], $bbox[4], $bbox[6]]);
+        $minY = min([$bbox[1], $bbox[3], $bbox[5], $bbox[7]]);
+        $maxY = max([$bbox[1], $bbox[3], $bbox[5], $bbox[7]]);
+
+        return [
+            'left' => abs($minX) - 1,
+            'top' => abs($minY),
+            'width' => $maxX - $minX,
+            'height' => $maxY - $minY,
+            'box' => $bbox,
+        ];
+    }
+
     private function addTextWithBox(
         GdImage $image,
         int $x,
@@ -471,7 +522,7 @@ class VideoClip
     ): bool {
         // Create some colors
         $white = imagecolorallocate($image, 255, 255, 255);
-        $bgColor = imagecolorallocate($image, 0, 86, 74);
+        $bgColor = imagecolorallocate($image, 0, 61, 11);
 
         if ($white === false || $bgColor === false) {
             return false;
@@ -495,33 +546,64 @@ class VideoClip
     }
 
     /**
-     * Adapted from: https://www.php.net/manual/fr/function.imagettfbbox.php#105593
-     *
-     * @return array<string, mixed>|false
+     * @return array<int, string>|false
      */
-    private function calculateTextBox(int $fontSize, int $fontAngle, string $fontFile, string $text): array | false
-    {
-        /************
-        simple function that calculates the *exact* bounding box (single pixel precision).
-        The function returns an associative array with these keys:
-        left, top:  coordinates you will pass to imagettftext
-        width, height: dimension of the image you have to create
-        *************/
-        $bbox = imagettfbbox($fontSize, $fontAngle, $fontFile, $text);
+    private function textToParagraph(
+        string $text,
+        string $fontPath,
+        int $fontsize,
+        int $lineWidth,
+        int $numberOfLines,
+        int $paragraphIndent = 0,
+    ): array | false {
+        // check length of text
+        $bbox = $this->calculateTextBox($fontsize, 0, $fontPath, $text);
         if (! $bbox) {
             return false;
         }
-        $minX = min([$bbox[0], $bbox[2], $bbox[4], $bbox[6]]);
-        $maxX = max([$bbox[0], $bbox[2], $bbox[4], $bbox[6]]);
-        $minY = min([$bbox[1], $bbox[3], $bbox[5], $bbox[7]]);
-        $maxY = max([$bbox[1], $bbox[3], $bbox[5], $bbox[7]]);
 
-        return [
-            'left' => abs($minX) - 1,
-            'top' => abs($minY),
-            'width' => $maxX - $minX,
-            'height' => $maxY - $minY,
-            'box' => $bbox,
-        ];
+        // return early if text width is less than line width
+        if ($bbox['width'] <= $lineWidth) {
+            return [$text];
+        }
+
+        // cut text in multiple lines based on the lineWidth property
+        $lines = [''];
+        $length = $paragraphIndent;
+        $words = preg_split('~\b(?=\S)|(?=\s)~', $text);
+        if (! $words) {
+            return false;
+        }
+
+        $wordCount = count($words);
+        $lineNumber = 0;
+        for ($i = 0; $i < $wordCount; ++$i) {
+            $word = $words[$i];
+            $wordBox = $this->calculateTextBox($fontsize, 0, $fontPath, $word);
+            if (! $wordBox) {
+                return false;
+            }
+            $wordWidth = $wordBox['width'];
+
+            if (($wordWidth + $length) > $lineWidth) {
+                ++$lineNumber;
+                if ($lineNumber > $numberOfLines - 1) {
+                    $lines[$numberOfLines - 1] .= '…';
+                    break;
+                }
+                $lines[$lineNumber] = '';
+                $length = 0;
+
+                // If the current word is just a space, don't bother. Skip (saves a weird-looking gap in the text).
+                if ($word === ' ') {
+                    continue;
+                }
+            }
+
+            $lines[$lineNumber] .= $word;
+            $length += $wordWidth;
+        }
+
+        return $lines;
     }
 }
-- 
GitLab