Compare commits
2 commits
8c3ab65561
...
20fe32af52
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20fe32af52 | ||
|
|
e85d94f706 |
|
|
@ -190,14 +190,19 @@ final class ProFileGenerator
|
|||
private static function buildCue(string $cueUuid, array $slideData): Cue
|
||||
{
|
||||
$elements = [];
|
||||
if (isset($slideData['text'])) {
|
||||
$imageOnly = ($slideData['imageOnly'] ?? false) === true;
|
||||
|
||||
if (! $imageOnly && isset($slideData['text'])) {
|
||||
$hasTranslation = isset($slideData['translation']) && $slideData['translation'] !== null;
|
||||
|
||||
if ($hasTranslation) {
|
||||
$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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -220,6 +225,10 @@ final class ProFileGenerator
|
|||
|
||||
$actions = [self::buildSlideAction($slideType)];
|
||||
|
||||
if (isset($slideData['background']) && is_array($slideData['background'])) {
|
||||
$actions[] = self::buildBackgroundMediaAction($slideData['background']);
|
||||
}
|
||||
|
||||
if (isset($slideData['media'])) {
|
||||
// Derive name from label OR filename without extension
|
||||
$mediaName = $slideData['label'] ?? null;
|
||||
|
|
@ -302,8 +311,28 @@ final class ProFileGenerator
|
|||
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) {
|
||||
$filename = basename($absoluteUrl);
|
||||
$url = self::buildBundleRelativeUrl($filename);
|
||||
|
|
@ -355,7 +384,7 @@ final class ProFileGenerator
|
|||
$mediaElement->setImage($imageTypeProperties);
|
||||
|
||||
$mediaType = new MediaType();
|
||||
$mediaType->setLayerType(LayerType::LAYER_TYPE_FOREGROUND);
|
||||
$mediaType->setLayerType($layerType);
|
||||
$mediaType->setElement($mediaElement);
|
||||
$mediaType->setAudio(new Audio());
|
||||
|
||||
|
|
@ -386,7 +415,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());
|
||||
|
|
@ -400,7 +429,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);
|
||||
|
||||
|
|
@ -578,6 +607,7 @@ final class ProFileGenerator
|
|||
$color->setAlpha(1.0);
|
||||
$background = new Background();
|
||||
$background->setColor($color);
|
||||
|
||||
return $background;
|
||||
}
|
||||
|
||||
|
|
@ -585,6 +615,7 @@ final class ProFileGenerator
|
|||
{
|
||||
$url = new URL();
|
||||
$url->setPlatform(UrlPlatform::PLATFORM_MACOS);
|
||||
|
||||
return $url;
|
||||
}
|
||||
|
||||
|
|
@ -592,6 +623,7 @@ final class ProFileGenerator
|
|||
{
|
||||
$timeline = new Timeline();
|
||||
$timeline->setDuration(300.0);
|
||||
|
||||
return $timeline;
|
||||
}
|
||||
|
||||
|
|
@ -642,11 +674,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;}
|
||||
|
|
@ -654,7 +698,7 @@ final class ProFileGenerator
|
|||
\deftab1680
|
||||
\pard\pardeftab1680\pardirnatural\qc\partightenfactor0
|
||||
|
||||
\f0\fs84 \cf2 \CocoaLigature0 ENCODED_TEXT_HERE}
|
||||
\f0BODY_HERE}
|
||||
RTF);
|
||||
}
|
||||
|
||||
|
|
@ -730,8 +774,8 @@ RTF);
|
|||
private static function newUuidString(): string
|
||||
{
|
||||
$bytes = random_bytes(16);
|
||||
$bytes[6] = chr((ord($bytes[6]) & 0x0f) | 0x40);
|
||||
$bytes[8] = chr((ord($bytes[8]) & 0x3f) | 0x80);
|
||||
$bytes[6] = chr((ord($bytes[6]) & 0x0F) | 0x40);
|
||||
$bytes[8] = chr((ord($bytes[8]) & 0x3F) | 0x80);
|
||||
$hex = bin2hex($bytes);
|
||||
|
||||
return strtoupper(sprintf(
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ namespace ProPresenter\Parser;
|
|||
|
||||
use Rv\Data\Action;
|
||||
use Rv\Data\Action\ActionType;
|
||||
use Rv\Data\Action\LayerType;
|
||||
use Rv\Data\Action\MacroType;
|
||||
use Rv\Data\CollectionElementType;
|
||||
use Rv\Data\Cue;
|
||||
|
|
@ -118,6 +119,7 @@ class Slide
|
|||
public function getTranslation(): ?TextElement
|
||||
{
|
||||
$textElements = $this->getTextElements();
|
||||
|
||||
return $textElements[1] ?? null;
|
||||
}
|
||||
|
||||
|
|
@ -149,24 +151,28 @@ class Slide
|
|||
public function getMacroName(): ?string
|
||||
{
|
||||
$macro = $this->findMacroAction();
|
||||
|
||||
return $macro?->getMacro()?->getIdentification()?->getParameterName();
|
||||
}
|
||||
|
||||
public function getMacroUuid(): ?string
|
||||
{
|
||||
$macro = $this->findMacroAction();
|
||||
|
||||
return $macro?->getMacro()?->getIdentification()?->getParameterUuid()?->getString();
|
||||
}
|
||||
|
||||
public function getMacroCollectionName(): ?string
|
||||
{
|
||||
$macro = $this->findMacroAction();
|
||||
|
||||
return $macro?->getMacro()?->getIdentification()?->getParentCollection()?->getParameterName();
|
||||
}
|
||||
|
||||
public function getMacroCollectionUuid(): ?string
|
||||
{
|
||||
$macro = $this->findMacroAction();
|
||||
|
||||
return $macro?->getMacro()?->getIdentification()?->getParentCollection()?->getParameterUuid()?->getString();
|
||||
}
|
||||
|
||||
|
|
@ -195,6 +201,7 @@ class Slide
|
|||
$existingMacroAction->setType(ActionType::ACTION_TYPE_MACRO);
|
||||
$existingMacroAction->setMacro($macroType);
|
||||
$existingMacroAction->setIsEnabled(true);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -232,18 +239,40 @@ class Slide
|
|||
public function getMediaUrl(): ?string
|
||||
{
|
||||
$media = $this->findMediaAction();
|
||||
|
||||
return $media?->getMedia()?->getElement()?->getUrl()?->getAbsoluteString();
|
||||
}
|
||||
|
||||
public function getMediaUuid(): ?string
|
||||
{
|
||||
$media = $this->findMediaAction();
|
||||
|
||||
return $media?->getMedia()?->getElement()?->getUuid()?->getString();
|
||||
}
|
||||
|
||||
public function getMediaFormat(): ?string
|
||||
{
|
||||
$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();
|
||||
}
|
||||
|
||||
|
|
@ -304,9 +333,19 @@ class Slide
|
|||
}
|
||||
|
||||
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) {
|
||||
if ($action->getType() === ActionType::ACTION_TYPE_MEDIA) {
|
||||
if ($action->getType() === ActionType::ACTION_TYPE_MEDIA && $action->getMedia()?->getLayerType() === $layerType) {
|
||||
return $action;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
140
tests/BackgroundLayerTest.php
Normal file
140
tests/BackgroundLayerTest.php
Normal 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;
|
||||
}
|
||||
}
|
||||
101
tests/NameTagSubtitleRtfTest.php
Normal file
101
tests/NameTagSubtitleRtfTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue