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
This commit is contained in:
Thorsten Bus 2026-06-21 09:58:24 +02:00
parent 582ef85c2a
commit d187b092e3
2 changed files with 122 additions and 6 deletions

View file

@ -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);
}

View file

@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
namespace ProPresenter\Parser\Tests;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use ProPresenter\Parser\ProFileGenerator;
final class NameTagSubtitleRtfTest extends TestCase
{
/**
* Pull the RTF data of the first slide's first text element.
*/
private function firstSlideRtf(array $slideData): string
{
$song = ProFileGenerator::generate(
'Subtitle Test',
[
[
'name' => '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);
}
}