Compare commits

...

2 commits

Author SHA1 Message Date
Thorsten Bus 20fe32af52 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
2026-07-06 10:03:13 +02:00
Thorsten Bus e85d94f706 feat(parser): background-layer + image-only slide support 2026-07-06 10:03:13 +02:00
4 changed files with 339 additions and 15 deletions

View file

@ -190,14 +190,19 @@ final class ProFileGenerator
private static function buildCue(string $cueUuid, array $slideData): Cue private static function buildCue(string $cueUuid, array $slideData): Cue
{ {
$elements = []; $elements = [];
if (isset($slideData['text'])) { $imageOnly = ($slideData['imageOnly'] ?? false) === true;
if (! $imageOnly && isset($slideData['text'])) {
$hasTranslation = isset($slideData['translation']) && $slideData['translation'] !== null; $hasTranslation = isset($slideData['translation']) && $slideData['translation'] !== null;
if ($hasTranslation) { if ($hasTranslation) {
$elements[] = self::buildSlideElement('Orginal', (string) $slideData['text'], self::buildOriginalBounds()); $elements[] = self::buildSlideElement('Orginal', (string) $slideData['text'], self::buildOriginalBounds());
$elements[] = self::buildSlideElement('Deutsch', (string) $slideData['translation'], self::buildTranslationBounds()); $elements[] = self::buildSlideElement('Deutsch', (string) $slideData['translation'], self::buildTranslationBounds());
} else { } 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);
} }
} }
@ -220,6 +225,10 @@ final class ProFileGenerator
$actions = [self::buildSlideAction($slideType)]; $actions = [self::buildSlideAction($slideType)];
if (isset($slideData['background']) && is_array($slideData['background'])) {
$actions[] = self::buildBackgroundMediaAction($slideData['background']);
}
if (isset($slideData['media'])) { if (isset($slideData['media'])) {
// Derive name from label OR filename without extension // Derive name from label OR filename without extension
$mediaName = $slideData['label'] ?? null; $mediaName = $slideData['label'] ?? null;
@ -302,8 +311,28 @@ final class ProFileGenerator
return $action; return $action;
} }
private static function buildMediaAction(string $absoluteUrl, string $format, ?string $name = null, int $imageWidth = 0, int $imageHeight = 0, bool $bundleRelative = false): Action private static function buildBackgroundMediaAction(array $backgroundData): Action
{ {
return self::buildMediaAction(
(string) ($backgroundData['path'] ?? ''),
(string) ($backgroundData['format'] ?? 'JPG'),
null,
(int) ($backgroundData['width'] ?? 1920),
(int) ($backgroundData['height'] ?? 1080),
(bool) ($backgroundData['bundleRelative'] ?? false),
LayerType::LAYER_TYPE_BACKGROUND,
);
}
private static function buildMediaAction(
string $absoluteUrl,
string $format,
?string $name = null,
int $imageWidth = 0,
int $imageHeight = 0,
bool $bundleRelative = false,
int $layerType = LayerType::LAYER_TYPE_FOREGROUND,
): Action {
if ($bundleRelative) { if ($bundleRelative) {
$filename = basename($absoluteUrl); $filename = basename($absoluteUrl);
$url = self::buildBundleRelativeUrl($filename); $url = self::buildBundleRelativeUrl($filename);
@ -355,7 +384,7 @@ final class ProFileGenerator
$mediaElement->setImage($imageTypeProperties); $mediaElement->setImage($imageTypeProperties);
$mediaType = new MediaType(); $mediaType = new MediaType();
$mediaType->setLayerType(LayerType::LAYER_TYPE_FOREGROUND); $mediaType->setLayerType($layerType);
$mediaType->setElement($mediaElement); $mediaType->setElement($mediaElement);
$mediaType->setAudio(new Audio()); $mediaType->setAudio(new Audio());
@ -386,7 +415,7 @@ final class ProFileGenerator
return $url; 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 = new GraphicsElement();
$graphicsElement->setUuid(self::newUuid()); $graphicsElement->setUuid(self::newUuid());
@ -400,7 +429,7 @@ final class ProFileGenerator
$graphicsElement->setFeather(self::buildFeather()); $graphicsElement->setFeather(self::buildFeather());
$graphicsText = new Text(); $graphicsText = new Text();
$graphicsText->setRtfData(self::buildRtf($text)); $graphicsText->setRtfData(self::buildRtf($text, $subtitle));
$graphicsText->setVerticalAlignment(VerticalAlignment::VERTICAL_ALIGNMENT_MIDDLE); $graphicsText->setVerticalAlignment(VerticalAlignment::VERTICAL_ALIGNMENT_MIDDLE);
$graphicsElement->setText($graphicsText); $graphicsElement->setText($graphicsText);
@ -578,6 +607,7 @@ final class ProFileGenerator
$color->setAlpha(1.0); $color->setAlpha(1.0);
$background = new Background(); $background = new Background();
$background->setColor($color); $background->setColor($color);
return $background; return $background;
} }
@ -585,6 +615,7 @@ final class ProFileGenerator
{ {
$url = new URL(); $url = new URL();
$url->setPlatform(UrlPlatform::PLATFORM_MACOS); $url->setPlatform(UrlPlatform::PLATFORM_MACOS);
return $url; return $url;
} }
@ -592,6 +623,7 @@ final class ProFileGenerator
{ {
$timeline = new Timeline(); $timeline = new Timeline();
$timeline->setDuration(300.0); $timeline->setDuration(300.0);
return $timeline; return $timeline;
} }
@ -642,11 +674,23 @@ final class ProFileGenerator
$presentation->setCcli($metadata); $presentation->setCcli($metadata);
} }
private static function buildRtf(string $text): string private static function buildRtf(string $text, ?string $subtitle = null): string
{ {
$encodedText = self::encodePlainTextForRtf($text); $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 {\rtf1\ansi\ansicpg1252\cocoartf2761
\cocoatextscaling0\cocoaplatform0{\fonttbl\f0\fnil\fcharset0 HelveticaNeue;} \cocoatextscaling0\cocoaplatform0{\fonttbl\f0\fnil\fcharset0 HelveticaNeue;}
{\colortbl;\red255\green255\blue255;\red255\green255\blue255;} {\colortbl;\red255\green255\blue255;\red255\green255\blue255;}
@ -654,7 +698,7 @@ final class ProFileGenerator
\deftab1680 \deftab1680
\pard\pardeftab1680\pardirnatural\qc\partightenfactor0 \pard\pardeftab1680\pardirnatural\qc\partightenfactor0
\f0\fs84 \cf2 \CocoaLigature0 ENCODED_TEXT_HERE} \f0BODY_HERE}
RTF); RTF);
} }
@ -730,8 +774,8 @@ RTF);
private static function newUuidString(): string private static function newUuidString(): string
{ {
$bytes = random_bytes(16); $bytes = random_bytes(16);
$bytes[6] = chr((ord($bytes[6]) & 0x0f) | 0x40); $bytes[6] = chr((ord($bytes[6]) & 0x0F) | 0x40);
$bytes[8] = chr((ord($bytes[8]) & 0x3f) | 0x80); $bytes[8] = chr((ord($bytes[8]) & 0x3F) | 0x80);
$hex = bin2hex($bytes); $hex = bin2hex($bytes);
return strtoupper(sprintf( return strtoupper(sprintf(

View file

@ -6,6 +6,7 @@ namespace ProPresenter\Parser;
use Rv\Data\Action; use Rv\Data\Action;
use Rv\Data\Action\ActionType; use Rv\Data\Action\ActionType;
use Rv\Data\Action\LayerType;
use Rv\Data\Action\MacroType; use Rv\Data\Action\MacroType;
use Rv\Data\CollectionElementType; use Rv\Data\CollectionElementType;
use Rv\Data\Cue; use Rv\Data\Cue;
@ -118,6 +119,7 @@ class Slide
public function getTranslation(): ?TextElement public function getTranslation(): ?TextElement
{ {
$textElements = $this->getTextElements(); $textElements = $this->getTextElements();
return $textElements[1] ?? null; return $textElements[1] ?? null;
} }
@ -149,24 +151,28 @@ class Slide
public function getMacroName(): ?string public function getMacroName(): ?string
{ {
$macro = $this->findMacroAction(); $macro = $this->findMacroAction();
return $macro?->getMacro()?->getIdentification()?->getParameterName(); return $macro?->getMacro()?->getIdentification()?->getParameterName();
} }
public function getMacroUuid(): ?string public function getMacroUuid(): ?string
{ {
$macro = $this->findMacroAction(); $macro = $this->findMacroAction();
return $macro?->getMacro()?->getIdentification()?->getParameterUuid()?->getString(); return $macro?->getMacro()?->getIdentification()?->getParameterUuid()?->getString();
} }
public function getMacroCollectionName(): ?string public function getMacroCollectionName(): ?string
{ {
$macro = $this->findMacroAction(); $macro = $this->findMacroAction();
return $macro?->getMacro()?->getIdentification()?->getParentCollection()?->getParameterName(); return $macro?->getMacro()?->getIdentification()?->getParentCollection()?->getParameterName();
} }
public function getMacroCollectionUuid(): ?string public function getMacroCollectionUuid(): ?string
{ {
$macro = $this->findMacroAction(); $macro = $this->findMacroAction();
return $macro?->getMacro()?->getIdentification()?->getParentCollection()?->getParameterUuid()?->getString(); return $macro?->getMacro()?->getIdentification()?->getParentCollection()?->getParameterUuid()?->getString();
} }
@ -195,6 +201,7 @@ class Slide
$existingMacroAction->setType(ActionType::ACTION_TYPE_MACRO); $existingMacroAction->setType(ActionType::ACTION_TYPE_MACRO);
$existingMacroAction->setMacro($macroType); $existingMacroAction->setMacro($macroType);
$existingMacroAction->setIsEnabled(true); $existingMacroAction->setIsEnabled(true);
return; return;
} }
@ -232,18 +239,40 @@ class Slide
public function getMediaUrl(): ?string public function getMediaUrl(): ?string
{ {
$media = $this->findMediaAction(); $media = $this->findMediaAction();
return $media?->getMedia()?->getElement()?->getUrl()?->getAbsoluteString(); return $media?->getMedia()?->getElement()?->getUrl()?->getAbsoluteString();
} }
public function getMediaUuid(): ?string public function getMediaUuid(): ?string
{ {
$media = $this->findMediaAction(); $media = $this->findMediaAction();
return $media?->getMedia()?->getElement()?->getUuid()?->getString(); return $media?->getMedia()?->getElement()?->getUuid()?->getString();
} }
public function getMediaFormat(): ?string public function getMediaFormat(): ?string
{ {
$media = $this->findMediaAction(); $media = $this->findMediaAction();
return $media?->getMedia()?->getElement()?->getMetadata()?->getFormat();
}
public function hasBackgroundMedia(): bool
{
return $this->findBackgroundMediaAction() !== null;
}
public function getBackgroundMediaUrl(): ?string
{
$media = $this->findBackgroundMediaAction();
return $media?->getMedia()?->getElement()?->getUrl()?->getAbsoluteString();
}
public function getBackgroundMediaFormat(): ?string
{
$media = $this->findBackgroundMediaAction();
return $media?->getMedia()?->getElement()?->getMetadata()?->getFormat(); return $media?->getMedia()?->getElement()?->getMetadata()?->getFormat();
} }
@ -304,9 +333,19 @@ class Slide
} }
private function findMediaAction(): ?Action private function findMediaAction(): ?Action
{
return $this->findMediaActionByLayerType(LayerType::LAYER_TYPE_FOREGROUND);
}
private function findBackgroundMediaAction(): ?Action
{
return $this->findMediaActionByLayerType(LayerType::LAYER_TYPE_BACKGROUND);
}
private function findMediaActionByLayerType(int $layerType): ?Action
{ {
foreach ($this->cue->getActions() as $action) { foreach ($this->cue->getActions() as $action) {
if ($action->getType() === ActionType::ACTION_TYPE_MEDIA) { if ($action->getType() === ActionType::ACTION_TYPE_MEDIA && $action->getMedia()?->getLayerType() === $layerType) {
return $action; return $action;
} }
} }

View file

@ -0,0 +1,140 @@
<?php
declare(strict_types=1);
namespace ProPresenter\Parser\Tests;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use ProPresenter\Parser\ProFileGenerator;
use Rv\Data\Action;
use Rv\Data\Action\ActionType;
use Rv\Data\Action\LayerType;
class BackgroundLayerTest extends TestCase
{
#[Test]
public function generated_text_slide_can_have_background_layer_media(): void
{
$song = ProFileGenerator::generate(
'Background Text Song',
[[
'name' => 'Verse 1',
'color' => [0.1, 0.2, 0.3, 1.0],
'slides' => [[
'text' => 'Text auf Folie',
'background' => [
'path' => 'file:///tmp/bg.jpg',
'format' => 'JPG',
'width' => 1920,
'height' => 1080,
],
]],
]],
[['name' => 'normal', 'groupNames' => ['Verse 1']]],
);
$slide = $song->getSlides()[0];
$mediaActions = self::mediaActions($slide->getCue()->getActions());
$this->assertCount(1, $slide->getTextElements());
$this->assertSame('Text auf Folie', $slide->getPlainText());
$this->assertCount(1, $mediaActions);
$this->assertSame(LayerType::LAYER_TYPE_BACKGROUND, $mediaActions[0]->getMedia()->getLayerType());
$this->assertSame('file:///tmp/bg.jpg', $mediaActions[0]->getMedia()->getElement()->getUrl()->getAbsoluteString());
$this->assertSame('JPG', $mediaActions[0]->getMedia()->getElement()->getMetadata()->getFormat());
$this->assertSame(1920.0, $mediaActions[0]->getMedia()->getElement()->getImage()->getDrawing()->getNaturalSize()->getWidth());
$this->assertSame(1080.0, $mediaActions[0]->getMedia()->getElement()->getImage()->getDrawing()->getNaturalSize()->getHeight());
$this->assertTrue($slide->hasBackgroundMedia());
$this->assertSame('file:///tmp/bg.jpg', $slide->getBackgroundMediaUrl());
$this->assertSame('JPG', $slide->getBackgroundMediaFormat());
}
#[Test]
public function generated_image_only_slide_has_no_text_elements(): void
{
$song = ProFileGenerator::generate(
'Image Only Background Song',
[[
'name' => 'Image',
'color' => [0.1, 0.2, 0.3, 1.0],
'slides' => [[
'text' => 'Dieser Text darf nicht erscheinen',
'imageOnly' => true,
'background' => [
'path' => 'file:///tmp/image-only.jpg',
'format' => 'JPG',
'width' => 1920,
'height' => 1080,
],
]],
]],
[['name' => 'normal', 'groupNames' => ['Image']]],
);
$slide = $song->getSlides()[0];
$mediaActions = self::mediaActions($slide->getCue()->getActions());
$this->assertCount(0, $slide->getTextElements());
$this->assertSame('', $slide->getPlainText());
$this->assertCount(1, $mediaActions);
$this->assertSame(LayerType::LAYER_TYPE_BACKGROUND, $mediaActions[0]->getMedia()->getLayerType());
$this->assertTrue($slide->hasBackgroundMedia());
$this->assertSame('file:///tmp/image-only.jpg', $slide->getBackgroundMediaUrl());
}
#[Test]
public function generated_slide_can_have_background_and_foreground_media_in_order(): void
{
$song = ProFileGenerator::generate(
'Mixed Media Song',
[[
'name' => 'Verse 1',
'color' => [0.1, 0.2, 0.3, 1.0],
'slides' => [[
'text' => 'Text vor Bild',
'background' => [
'path' => 'file:///tmp/background.jpg',
'format' => 'JPG',
'width' => 1920,
'height' => 1080,
],
'media' => 'file:///tmp/foreground.png',
'format' => 'PNG',
'mediaWidth' => 640,
'mediaHeight' => 360,
]],
]],
[['name' => 'normal', 'groupNames' => ['Verse 1']]],
);
$slide = $song->getSlides()[0];
$mediaActions = self::mediaActions($slide->getCue()->getActions());
$this->assertCount(2, $mediaActions);
$this->assertSame(LayerType::LAYER_TYPE_BACKGROUND, $mediaActions[0]->getMedia()->getLayerType());
$this->assertSame(LayerType::LAYER_TYPE_FOREGROUND, $mediaActions[1]->getMedia()->getLayerType());
$this->assertSame('file:///tmp/background.jpg', $mediaActions[0]->getMedia()->getElement()->getUrl()->getAbsoluteString());
$this->assertSame('file:///tmp/foreground.png', $mediaActions[1]->getMedia()->getElement()->getUrl()->getAbsoluteString());
$this->assertTrue($slide->hasBackgroundMedia());
$this->assertTrue($slide->hasMedia());
$this->assertSame('file:///tmp/background.jpg', $slide->getBackgroundMediaUrl());
$this->assertSame('file:///tmp/foreground.png', $slide->getMediaUrl());
}
/**
* @param iterable<Action> $actions
* @return Action[]
*/
private static function mediaActions(iterable $actions): array
{
$mediaActions = [];
foreach ($actions as $action) {
if ($action->getType() === ActionType::ACTION_TYPE_MEDIA) {
$mediaActions[] = $action;
}
}
return $mediaActions;
}
}

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