From d187b092e31d76415e6002893aa7d3ac9afde984 Mon Sep 17 00:00:00 2001 From: Thorsten Bus Date: Sun, 21 Jun 2026 09:58:24 +0200 Subject: [PATCH] feat(generator): render nametag subtitle as smaller non-bold RTF run - subtitle now emitted as second run (\b0\fs50); main text unchanged (\fs84) - slides without subtitle byte-identical to previous single-run output - add NameTagSubtitleRtfTest --- src/ProFileGenerator.php | 27 +++++++-- tests/NameTagSubtitleRtfTest.php | 101 +++++++++++++++++++++++++++++++ 2 files changed, 122 insertions(+), 6 deletions(-) create mode 100644 tests/NameTagSubtitleRtfTest.php diff --git a/src/ProFileGenerator.php b/src/ProFileGenerator.php index 85b343b..9294897 100644 --- a/src/ProFileGenerator.php +++ b/src/ProFileGenerator.php @@ -189,7 +189,10 @@ final class ProFileGenerator $elements[] = self::buildSlideElement('Orginal', (string) $slideData['text'], self::buildOriginalBounds()); $elements[] = self::buildSlideElement('Deutsch', (string) $slideData['translation'], self::buildTranslationBounds()); } else { - $elements[] = self::buildSlideElement('Orginal', (string) $slideData['text']); + $subtitle = isset($slideData['subtitle']) && $slideData['subtitle'] !== null + ? (string) $slideData['subtitle'] + : null; + $elements[] = self::buildSlideElement('Orginal', (string) $slideData['text'], null, $subtitle); } } @@ -392,7 +395,7 @@ final class ProFileGenerator return $url; } - private static function buildSlideElement(string $name, string $text, ?Rect $bounds = null): SlideElement + private static function buildSlideElement(string $name, string $text, ?Rect $bounds = null, ?string $subtitle = null): SlideElement { $graphicsElement = new GraphicsElement(); $graphicsElement->setUuid(self::newUuid()); @@ -406,7 +409,7 @@ final class ProFileGenerator $graphicsElement->setFeather(self::buildFeather()); $graphicsText = new Text(); - $graphicsText->setRtfData(self::buildRtf($text)); + $graphicsText->setRtfData(self::buildRtf($text, $subtitle)); $graphicsText->setVerticalAlignment(VerticalAlignment::VERTICAL_ALIGNMENT_MIDDLE); $graphicsElement->setText($graphicsText); @@ -635,11 +638,23 @@ final class ProFileGenerator $presentation->setCcli($metadata); } - private static function buildRtf(string $text): string + private static function buildRtf(string $text, ?string $subtitle = null): string { $encodedText = self::encodePlainTextForRtf($text); - return str_replace('ENCODED_TEXT_HERE', $encodedText, <<<'RTF' + // Main text: \fs84. When a subtitle is provided, append it as a second + // run on a new line that is explicitly non-bold (\b0) and smaller (\fs50, + // ~60% of 84). Slides without a subtitle stay a single \fs84 run and the + // exact byte output is identical to the previous single-run template. + $body = '\fs84 \cf2 \CocoaLigature0 '.$encodedText; + + if ($subtitle !== null && trim($subtitle) !== '') { + $encodedSubtitle = self::encodePlainTextForRtf($subtitle); + $body .= '\ +'.'\b0\fs50 '.$encodedSubtitle; + } + + return str_replace('BODY_HERE', $body, <<<'RTF' {\rtf1\ansi\ansicpg1252\cocoartf2761 \cocoatextscaling0\cocoaplatform0{\fonttbl\f0\fnil\fcharset0 HelveticaNeue;} {\colortbl;\red255\green255\blue255;\red255\green255\blue255;} @@ -647,7 +662,7 @@ final class ProFileGenerator \deftab1680 \pard\pardeftab1680\pardirnatural\qc\partightenfactor0 -\f0\fs84 \cf2 \CocoaLigature0 ENCODED_TEXT_HERE} +\f0BODY_HERE} RTF); } diff --git a/tests/NameTagSubtitleRtfTest.php b/tests/NameTagSubtitleRtfTest.php new file mode 100644 index 0000000..48495b7 --- /dev/null +++ b/tests/NameTagSubtitleRtfTest.php @@ -0,0 +1,101 @@ + 'V1', + 'color' => [0, 0, 0, 1], + 'slides' => [$slideData], + ], + ], + [ + ['name' => 'normal', 'groupNames' => ['V1']], + ], + ); + + $cue = $song->getPresentation()->getCues()[0]; + $baseSlide = $cue->getActions()[0]->getSlide()->getPresentation()->getBaseSlide(); + $elements = $baseSlide->getElements(); + + $this->assertNotEmpty($elements, 'Slide must have at least one element'); + + return $elements[0]->getElement()->getText()->getRtfData(); + } + + #[Test] + public function subtitle_renders_as_separate_non_bold_smaller_run(): void + { + $rtf = $this->firstSlideRtf([ + 'text' => 'Max Mustermann', + 'subtitle' => 'Moderation', + ]); + + // Main text keeps \fs84. + $this->assertStringContainsString('\fs84', $rtf); + $this->assertStringContainsString('Max Mustermann', $rtf); + + // Subtitle is a separate run that is explicitly non-bold (\b0) and smaller (\fs50). + $this->assertStringContainsString('\b0\fs50', $rtf); + $this->assertStringContainsString('Moderation', $rtf); + + // The subtitle run must come after the main text. + $this->assertLessThan( + strpos($rtf, '\b0\fs50'), + strpos($rtf, 'Max Mustermann'), + 'Main text must precede the subtitle run', + ); + + // The subtitle run must follow the main \fs84 run. + $this->assertLessThan( + strpos($rtf, '\b0\fs50'), + strpos($rtf, '\fs84'), + '\fs84 main run must precede the \fs50 subtitle run', + ); + } + + #[Test] + public function slide_without_subtitle_is_unchanged_single_run(): void + { + $rtf = $this->firstSlideRtf([ + 'text' => 'Max Mustermann', + ]); + + // Exact single-run body identical to the legacy output. + $this->assertStringContainsString( + '\f0\fs84 \cf2 \CocoaLigature0 Max Mustermann', + $rtf, + ); + + // No subtitle styling leaks into lyric/plain slides. + $this->assertStringNotContainsString('\b0', $rtf); + $this->assertStringNotContainsString('\fs50', $rtf); + } + + #[Test] + public function empty_subtitle_is_treated_as_no_subtitle(): void + { + $rtf = $this->firstSlideRtf([ + 'text' => 'Max Mustermann', + 'subtitle' => ' ', + ]); + + $this->assertStringNotContainsString('\b0', $rtf); + $this->assertStringNotContainsString('\fs50', $rtf); + } +}