Compare commits

..

No commits in common. "94170857b67118abe66ce6e2749e4571089ee3f5" and "2a68d098a2361a4471f88558e5f43da1524892ee" have entirely different histories.

25 changed files with 540 additions and 2095 deletions

View file

@ -1,3 +1,8 @@
ls tests/fixtures/ccli/*.txt | wc -l
22
grep -rl "Strophe\|Refrain" tests/fixtures/ccli/ | wc -l
4
grep -rl "(Repeat)" tests/fixtures/ccli/ | wc -l
2

View file

@ -1,10 +1,9 @@
ddev exec php artisan test --filter=CcliFixtureSanityTest
PASS Tests\Feature\CcliFixtureSanityTest
✓ ccli fixture corpus has at least 20 txt files 0.23s
✓ ccli fixture corpus has at least 20 txt files
✓ each ccli fixture is valid utf8 with section labels and title
✓ ccli fixture corpus covers german labels
✓ ccli fixture corpus covers repeat markers
Tests: 4 passed (160 assertions)
Duration: 0.35s

View file

@ -33,53 +33,3 @@ ### T7: Global Label Slide Replacement Caveat
### T8: Translation Pairing Leaves Missing Sections Non-Fatal
- `CcliTranslationPairingService` intentionally does not throw when a local arrangement label is absent from the CCLI paste.
- Missing labels are returned in `unmatched_labels`, and their mapping entries keep empty slide placeholders so `distributed_text` still aligns with the local arrangement shape.
## Code Quality Review (2026-05-11)
### Found
- **app/Services/CcliPasteParser.php:17-19** — Empty `if` block in constructor.
Constructor accepts optional closures (`$sectionDetector`, `$metadataDetector`) but the body
contains `if ($sectionDetector !== null || $metadataDetector !== null) { }` with no statements.
Dead code — same category as forbidden empty `catch` blocks. Remove the entire `if` statement.
### Passed
- Pint: 9 files clean (after fixing `-rw-------` perms on DTO + Service files)
- Tests: 503 passed, 0 failed
- Build: vite production build clean
- No `as any`, `@ts-ignore`, `console.log` in Vue
- No `@` error suppression in PHP
- German Du-form throughout (no `Sie`/`Ihre`)
- All `final` + `readonly` correct
- data-testid coverage complete on interactive Vue elements
### Side note — file permissions
Several new PHP files were created with `-rw-------` (owner-only). DDEV web container could not
read them, breaking Pint. Fixed by `chmod 644`. Likely an editor/umask issue worth investigating
in the dev environment to avoid recurrence.
### Resolution (2026-05-11 re-review)
- Empty `if` block in `CcliPasteParser.php` removed; constructor is now `) {}`.
- Pint PASS on the file.
- Tests: 503 passed.
- Build: clean.
- Vue: no forbidden patterns.
- Final verdict: APPROVE.
## QA Finding (2026-05-11): API auth broken via DDEV URL
**Root cause**: `APP_URL=http://pp-planer.test` in `.env`, but DDEV serves on `https://pp-planer.ddev.site`.
`config/sanctum.php` derives `SANCTUM_STATEFUL_DOMAINS` from `APP_URL` host → list contains `pp-planer.test` only.
Cookie-based auth on `/api/*` routes (`auth:sanctum` middleware) fails when accessed via `pp-planer.ddev.site`.
**Symptom**:
- `/api/ccli/preview` with `Accept: application/json``401 Unauthenticated`
- `/api/ccli/preview` with default `Accept: */*` (Vue dialog's fetch) → 200 HTML (auth redirect), then `res.json()` throws "Unexpected token '<'" → caught → dialog shows "Netzwerkfehler. Bitte versuche es erneut."
- E2E tests (`ccli-paste-import.spec.ts` tests 6+7) skip silently via `test.skip()` guards when API fails, masking this in CI.
**Fix**: Add to `.env` and `.env.example`:
```
SANCTUM_STATEFUL_DOMAINS=pp-planer.ddev.site,pp-planer.test,localhost,127.0.0.1
```
Or change `APP_URL` to `https://pp-planer.ddev.site` if DDEV is the canonical dev URL.
**Bookmarklet has matching issue**: `BookmarkletController` uses `Config::get('app.url')` → bookmarklet opens `http://pp-planer.test/songs/import-from-ccli-paste?prefill=...` instead of the ddev URL.

View file

@ -108,25 +108,3 @@ ### 2026-05-11 CcliPasteController (T10)
- `tests/fixtures/ccli/` (lowercase) is the canonical fixture directory; existing tests already declare a top-level `ccliFixturePath()` helper, so new test files need a uniquely-named helper to avoid `Cannot redeclare function` errors in Pest.
- Web route `songs.import-from-ccli-paste` needs the `auth` middleware (web-style redirect to login), while the API routes use sanctum (401 JSON response); the difference matters for unauthenticated test assertions (`assertRedirect(route('login'))` vs `assertUnauthorized()`).
- `CcliImportService::import()` throws `RuntimeException` for missing CCLI id and `InvalidArgumentException` (via parser) for parse failures; controller catches both to return 422 with a German message.
## 2026-05-11 Plan Compliance Audit
- CCLI SongSelect Import audit approved: 20/20 must-have checks present, 4/4 forbidden-pattern checks clean.
- Targeted PHP filter run passed: 140 tests, 807 assertions; npm build and Pint check also passed.
- Registered routes verified: api.ccli.preview, api.ccli.import, bookmarklets.ccli, songs.import-from-ccli-paste.
## QA Re-verification (2026-05-11): Sanctum fix confirmed
After applying `pp-planer.ddev.site` to the default stateful domains in `config/sanctum.php` and clearing config cache:
- `sanctum.stateful` resolves to `[pp-planer.test, pp-planer.ddev.site, localhost, localhost:8000, 127.0.0.1, 127.0.0.1:8000, ::1]`
- `/api/ccli/preview` returns 200 JSON when called from an authenticated DDEV browser session (was 401 before)
- End-to-end Vue dialog flow on `/songs`: paste lyrics → click "Vorschau" → preview metadata (CCLI-Nr, sections) renders. No "Netzwerkfehler" message.
- E2E test `paste fixture and preview shows metadata` now PASSES (was silently skipped before the fix).
- Suite result: 11 passed, 3 skipped (vs 10 passed, 4 skipped pre-fix). Net +1 test exercising the API endpoint.
Remaining skipped tests (3):
- `duplicate import shows error with edit link` — guarded skip when initial create import returns non-201. Likely a separate data-state or status-code issue (worth investigating but unrelated to the Sanctum fix).
- 2× CCLI translation pairing tests — were skipped before the fix too; not regressions.
Bookmarklet endpoint still serves the same JS payload, header `Content-Type: text/javascript`, body starts with `javascript:`. Note: the embedded `APP_URL` is still `http://pp-planer.test` (driven by `config('app.url')`), so a DDEV user clicking the bookmarklet on SongSelect still lands on the wrong host. That's an APP_URL / bookmarklet-config concern, not a Sanctum concern.

View file

@ -20,7 +20,7 @@ ## General
- There should be Button in the Top Bar, to refresh the Data from the CTS API and a timestamp with the latest refresh.
- LoggedIn User should be visible in the Top Bar
- every action should be immediately persistent, no separate "save" button required, unless explicitly described.
- ProPresenter `.pro` file parser/generator is implemented as a separate composer package (`bussnet/propresenter7-php-lib`) linked via a composer vcs repository
- ProPresenter `.pro` file parser/generator is implemented as a separate composer package (`propresenter/parser`) linked via path repository
## The Plan
@ -239,14 +239,14 @@ ### Routes
## Repository Structure
Two git repositories, both on their `master` branch, each with a git remote. `pp-planer` has one SSH remote (`origin` → `ssh://git@git.stadtmission-butzbach.de:2222/public/pp-planer.git`); `propresenter` has two HTTPS mirrors (`forgejo` → `https://git.stadtmission-butzbach.de/public/propresenter7-php-lib.git`, `origin``https://github.com/bussnet/propresenter7-php-lib.git`):
Two git repositories, both local (no remote):
| Repo | Path | Branch | Purpose |
|------|------|--------|---------|
| **pp-planer** | `/Users/thorsten/AI/pp-planer` | `master` | Laravel app (main codebase) |
| **propresenter** | `/Users/thorsten/AI/propresenter` | `master` | ProPresenter .pro/.proplaylist parser (composer vcs dependency) |
| **pp-planer** | `/Users/thorsten/AI/pp-planer` | `cts-presenter-app` | Laravel app (main codebase) |
| **propresenter** | `/Users/thorsten/AI/propresenter` | `propresenter-parser` | ProPresenter .pro/.proplaylist parser (composer path dependency) |
The parser is consumed via a `composer.json` vcs repository: `"url": "https://git.stadtmission-butzbach.de/public/propresenter7-php-lib.git"` (a local `"../propresenter"` path repository can be enabled for development via `use_local_pp_lib.sh`).
The parser is linked via `composer.json` path repository: `"url": "../propresenter"`.
## Build, Test, Lint Commands

View file

@ -6,7 +6,6 @@
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
class ExportProFileController extends Controller
@ -27,13 +26,7 @@ public function store(Request $request): JsonResponse
$created = [];
foreach ($request->file('files') as $file) {
// Store under a random basename but keep the REAL client extension.
// Laravel's store() names files by the guessed MIME extension, which
// turns a .probundle (a ZIP) into a .zip and breaks extension-based
// routing at export time.
$extension = strtolower($file->getClientOriginalExtension());
$basename = Str::random(40).($extension !== '' ? '.'.$extension : '');
$storedPath = $file->storeAs('export-pro-files/'.$type, $basename, 'local');
$storedPath = $file->store('export-pro-files/'.$type, 'local');
$maxOrder = ExportProFile::where('type', $type)->max('order') ?? 0;
$record = ExportProFile::create([

View file

@ -10,8 +10,6 @@
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Storage;
use ProPresenter\Parser\ProFileGenerator;
use ProPresenter\Parser\ProFileReader;
use ProPresenter\Parser\ProFileWriter;
use ProPresenter\Parser\ProPlaylistGenerator;
use ProPresenter\Parser\Zip64Fixer;
use ZipArchive;
@ -97,7 +95,6 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
$embeddedFiles,
$service,
'information',
'information',
);
$announcementInserted = true;
}
@ -140,8 +137,7 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
$skippedUnmatched++;
}
} else {
$partType = $this->backgroundPartTypeForAgendaItem($item);
$isSermon = $partType === 'sermon';
$isSermon = $this->backgroundPartTypeForAgendaItem($item) === 'sermon';
if ($isSermon) {
$this->addSermonIntroPresentation($service, $tempDir, $playlistItems, $embeddedFiles);
@ -157,8 +153,7 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
$playlistItems,
$embeddedFiles,
$service,
$partType,
$partType,
$this->backgroundPartTypeForAgendaItem($item),
);
if (count($playlistItems) > $countBefore) {
@ -176,8 +171,7 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
$playlistItems,
$embeddedFiles,
$service,
$partType,
$partType,
$this->backgroundPartTypeForAgendaItem($item),
);
if (count($playlistItems) > $countBefore) {
@ -209,7 +203,7 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
}
if (str_contains(mb_strtolower((string) $item->title), mb_strtolower($keyword))) {
$keywordItem = $this->embedExportProFile($kf, $embeddedFiles, $warnings);
$keywordItem = $this->embedExportProFile($kf, $embeddedFiles);
if ($keywordItem !== null) {
$playlistItems[] = $keywordItem;
}
@ -229,7 +223,6 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
$prependFiles,
$service,
'information',
'information',
);
$playlistItems = array_merge($prependItems, $playlistItems);
$embeddedFiles = array_merge($prependFiles, $embeddedFiles);
@ -240,7 +233,7 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
throw new \RuntimeException('Keine Songs mit Inhalt zum Exportieren gefunden.');
}
$this->injectExportProFiles($playlistItems, $embeddedFiles, $warnings);
$this->injectExportProFiles($playlistItems, $embeddedFiles);
$this->addAbspannPresentation($service, $tempDir, $playlistItems, $embeddedFiles);
@ -367,7 +360,7 @@ private function generatePlaylistLegacy(Service $service, bool $preview = false)
throw new \RuntimeException('Keine Songs mit Inhalt zum Exportieren gefunden.');
}
$this->injectExportProFiles($playlistItems, $embeddedFiles, $warnings);
$this->injectExportProFiles($playlistItems, $embeddedFiles);
$this->addAbspannPresentation($service, $tempDir, $playlistItems, $embeddedFiles);
@ -405,7 +398,6 @@ private function addSlidesFromCollection(
array &$embeddedFiles,
?Service $service = null,
?string $backgroundPartType = null,
?string $macroPartType = null,
): void {
$slideDataList = [];
$imageFiles = [];
@ -448,23 +440,6 @@ private function addSlidesFromCollection(
return;
}
if ($service !== null && $macroPartType !== null) {
$total = count($slideDataList);
foreach ($slideDataList as $i => &$slideData) {
$macros = $this->macroResolutionService->macrosForSlide(
$service,
$macroPartType,
['index' => $i, 'total' => $total, 'label_id' => null],
);
if (! empty($macros)) {
// ProPresenter parser currently supports one `macro` entry per slide
$slideData['macro'] = $macros[0];
}
}
unset($slideData);
}
$groups = [
[
'name' => $label,
@ -541,7 +516,6 @@ private function addSlidePresentation(
$embeddedFiles,
$service,
$type,
$type,
);
}
@ -606,30 +580,28 @@ private function addHeadlineItem(
];
}
private function injectExportProFiles(array &$playlistItems, array &$embeddedFiles, array &$warnings): void
private function injectExportProFiles(array &$playlistItems, array &$embeddedFiles): void
{
$prefixItems = $this->buildExportProItems(
ExportProFile::prefix()->orderBy('order')->get(),
$embeddedFiles,
$warnings,
);
$postfixItems = $this->buildExportProItems(
ExportProFile::postfix()->orderBy('order')->get(),
$embeddedFiles,
$warnings,
);
$playlistItems = array_merge($prefixItems, $playlistItems, $postfixItems);
}
/** @param \Illuminate\Support\Collection<int, ExportProFile> $files */
private function buildExportProItems(\Illuminate\Support\Collection $files, array &$embedded, array &$warnings): array
private function buildExportProItems(\Illuminate\Support\Collection $files, array &$embedded): array
{
$items = [];
foreach ($files as $file) {
$item = $this->embedExportProFile($file, $embedded, $warnings);
$item = $this->embedExportProFile($file, $embedded);
if ($item !== null) {
$items[] = $item;
}
@ -641,64 +613,34 @@ private function buildExportProItems(\Illuminate\Support\Collection $files, arra
/**
* Read a single export .pro/.probundle file from the 'local' disk, embed its
* bytes (and any .probundle media) into $embeddedFiles, and return the matching
* playlist presentation item. Returns null (and records a German warning) when
* the file is missing, a .probundle has no inner .pro entry, or the embedded
* .pro parses to zero content slides.
*
* The file type is derived from the ORIGINAL (already-validated) client
* filename, NOT from the stored path: Laravel's store() names uploads by the
* guessed MIME extension, so a .probundle (a ZIP) is persisted as .zip.
* Branching on the stored path would then miss the probundle branch and embed
* the raw ZIP bytes as a broken .pro (ProPresenter shows an empty slide).
* playlist presentation item. Returns null when the file is missing or a
* .probundle has no inner .pro entry.
*
* The embedded .pro name uses the uppercased type as marker
* (PREFIX/POSTFIX/KEYWORD) so prefix/postfix naming stays identical.
*
* @return array{type: string, name: string, path: string}|null
*/
private function embedExportProFile(ExportProFile $file, array &$embeddedFiles, array &$warnings): ?array
private function embedExportProFile(ExportProFile $file, array &$embeddedFiles): ?array
{
if (! Storage::disk('local')->exists($file->stored_path)) {
return null;
}
$ext = strtolower(pathinfo($file->original_name, PATHINFO_EXTENSION));
$ext = strtolower(pathinfo($file->stored_path, PATHINFO_EXTENSION));
$safeBase = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', pathinfo($file->original_name, PATHINFO_FILENAME));
$embeddedProName = strtoupper($file->type).'_'.$file->order.'_'.$safeBase.'.pro';
$displayName = pathinfo($file->original_name, PATHINFO_FILENAME);
// Treat both .probundle and .zip as bundles: a .probundle is a ZIP, and
// mis-stored uploads may end up with a .zip extension.
$isBundle = $ext === 'probundle' || $ext === 'zip';
$mediaFiles = [];
if ($isBundle) {
if ($ext === 'probundle') {
$storedAbsPath = Storage::disk('local')->path($file->stored_path);
$extracted = $this->extractProBundle($storedAbsPath);
if ($extracted === null) {
$warnings[] = "Export-Datei '".$file->original_name."' übersprungen: keine Folien gefunden.";
return null;
}
[$proBytes, $mediaFiles] = $extracted;
} else {
$proBytes = Storage::disk('local')->get($file->stored_path);
if ($proBytes === null) {
return null;
}
}
// Fail safe: a contentless export .pro would render an empty slide in
// ProPresenter. Skip it with a warning when it parses to zero content
// slides. Bytes that cannot be parsed at all (unverifiable) are embedded
// verbatim to preserve the legacy pass-through behaviour.
if ($this->contentSlideCountFromProBytes($proBytes) === 0) {
$warnings[] = "Export-Datei '".$file->original_name."' übersprungen: keine Folien gefunden.";
return null;
}
foreach ($mediaFiles as $entryName => $entryBytes) {
$mediaName = basename((string) $entryName);
@ -708,6 +650,14 @@ private function embedExportProFile(ExportProFile $file, array &$embeddedFiles,
}
$embeddedFiles[$embeddedProName] = $proBytes;
} else {
$bytes = Storage::disk('local')->get($file->stored_path);
if ($bytes === null) {
return null;
}
$embeddedFiles[$embeddedProName] = $bytes;
}
return [
'type' => 'presentation',
@ -716,42 +666,6 @@ private function embedExportProFile(ExportProFile $file, array &$embeddedFiles,
];
}
/**
* Count content slides across all groups of an embedded .pro payload. Returns
* null when the bytes cannot be parsed as a ProPresenter presentation, so the
* caller can distinguish "verified empty" (0) from "unverifiable" (null).
*/
private function contentSlideCountFromProBytes(string $proBytes): ?int
{
if ($proBytes === '') {
return 0;
}
$tempPath = tempnam(sys_get_temp_dir(), 'export-pro-verify-');
if ($tempPath === false) {
return null;
}
try {
if (@file_put_contents($tempPath, $proBytes) === false) {
return null;
}
$song = ProFileReader::read($tempPath);
$count = 0;
foreach ($song->getGroups() as $group) {
$count += count($song->getSlidesForGroup($group));
}
return $count;
} catch (\Throwable) {
return null;
} finally {
@unlink($tempPath);
}
}
/**
* Extract the inner .pro bytes (verbatim) and media files from a .probundle archive.
*
@ -838,12 +752,7 @@ private function extractProBundle(string $absPath): ?array
protected function writeProFile(string $path, string $name, array $groups, array $arrangements, array $options = []): void
{
// Split generate → inject → write: convert FOREGROUND (uploaded) image media
// actions into content fill.media ELEMENTS; BACKGROUND actions (service
// background / key-visual) stay as background-layer media actions.
$song = ProFileGenerator::generate($name, $groups, $arrangements, [], $options);
app(SlideMediaElementBuilder::class)->replaceImageActionsWithElements($song);
ProFileWriter::write($song, $path);
ProFileGenerator::generateAndWrite($path, $name, $groups, $arrangements, [], $options);
}
private function buildModeratorSlideData(Service $service): ?array

View file

@ -20,7 +20,6 @@ class ProBundleExportService
public function __construct(
private readonly MacroResolutionService $macroResolutionService,
private readonly ServiceImageResolver $imageResolver,
private readonly SlideMediaElementBuilder $slideMediaElementBuilder,
) {}
public function generateBundle(Service $service, string $blockType): string
@ -164,12 +163,6 @@ private function buildBundleFromSlides(
];
$song = ProFileGenerator::generate($groupName, $groups, $arrangements);
// Convert FOREGROUND (uploaded) image media actions into content fill.media
// ELEMENTS; the BACKGROUND service-image action is left untouched as a
// background-layer media action beneath the uploaded content.
$this->slideMediaElementBuilder->replaceImageActionsWithElements($song);
$proFilename = self::safeFilename($groupName).'.pro';
$bundle = new PresentationBundle($song, $proFilename, $mediaFiles);

View file

@ -13,7 +13,6 @@ class ProExportService
public function __construct(
private readonly MacroResolutionService $macroResolutionService,
private readonly ServiceImageResolver $imageResolver,
private readonly SlideMediaElementBuilder $slideMediaElementBuilder,
) {}
public function generateProFile(Song $song, ?Service $service = null, ?SongArrangement $selected = null): string
@ -37,11 +36,6 @@ public function generateParserSong(Song $song, ?Service $service = null, ?SongAr
$this->buildCcliMetadata($song),
);
// Convert FOREGROUND (uploaded) image media actions into content fill.media
// elements; the BACKGROUND service-image action is left untouched so it stays
// a background layer beneath the song text.
$this->slideMediaElementBuilder->replaceImageActionsWithElements($parserSong);
// Pre-select the chosen arrangement (protobuf field 10) by name so the export honours the
// per-service selection while keeping ALL arrangements available in the .pro file.
$chosen = $this->chosenArrangement($song, $selected);

View file

@ -1,352 +0,0 @@
<?php
namespace App\Services;
use ProPresenter\Parser\Song;
use Rv\Data\Action\ActionType;
use Rv\Data\Action\LayerType;
use Rv\Data\AlphaType;
use Rv\Data\Cue;
use Rv\Data\FileProperties;
use Rv\Data\Graphics\Element as GraphicsElement;
use Rv\Data\Graphics\Fill;
use Rv\Data\Graphics\Path;
use Rv\Data\Graphics\Path\BezierPoint;
use Rv\Data\Graphics\Path\Shape;
use Rv\Data\Graphics\Path\Shape\Type as ShapeType;
use Rv\Data\Graphics\Point;
use Rv\Data\Graphics\Rect;
use Rv\Data\Graphics\Size;
use Rv\Data\Media;
use Rv\Data\Media\DrawingProperties;
use Rv\Data\Media\ImageTypeProperties;
use Rv\Data\Media\Metadata;
use Rv\Data\Media\ScaleBehavior;
use Rv\Data\Slide as BaseSlide;
use Rv\Data\Slide\Element as SlideElement;
use Rv\Data\URL;
use Rv\Data\URL\LocalRelativePath;
use Rv\Data\URL\Platform as UrlPlatform;
use Rv\Data\UUID;
/**
* Converts a generated Song's FOREGROUND image media ACTIONS (uploaded slide
* images from the Information/Moderation/Sermon blocks and the abspann info
* images) into full-frame slide-content fill.media ELEMENTS, and strips those
* foreground actions so none survives.
*
* BACKGROUND-layer image media actions the service background image AND the
* key-visual are LEFT UNTOUCHED. They stay as media ACTIONS on the BACKGROUND
* layer so ProPresenter renders them behind the whole slide-content stack. Text,
* translation, subtitle, macro and completion actions are never touched.
*
* Combined rule (content vs background):
*
* - Background image / key-visual media ACTION, layer BACKGROUND (kept).
* - Uploaded content slide image fill.media content ELEMENT (converted).
* - Songtext / name-tag text text content elements (kept).
*
* Layering contract for the resulting base-slide element array (index 0 = bottom):
*
* [0 .. n-1] text elements from ProFileGenerator::generate()
* [n] uploaded (foreground) image element, appended on top
*
* The background image is NOT an element; it is a media action on the BACKGROUND
* layer and renders beneath the entire element stack automatically.
*
* Every converted foreground image is referenced bundle-relative
* (ROOT_CURRENT_RESOURCE) by its bare filename so it resolves against the bytes
* embedded into the .pro/.probundle archive never by an absolute path.
*/
class SlideMediaElementBuilder
{
/**
* Build a full-frame (0,0 1920×1080) content element whose fill is the
* given bundle-relative image.
*
* Images are pre-sized to 1920×1080 (16:9) before export, so FILL and FIT are
* equivalent; FILL is used as the default scale behaviour.
*/
public function mediaFillElement(
string $bundleRelativeFilename,
string $elementName = '',
int $width = 1920,
int $height = 1080,
int $scaleBehavior = ScaleBehavior::SCALE_BEHAVIOR_FILL,
): SlideElement {
$naturalSize = new Size;
$naturalSize->setWidth($width);
$naturalSize->setHeight($height);
$drawing = new DrawingProperties;
$drawing->setScaleBehavior($scaleBehavior);
$drawing->setNaturalSize($naturalSize);
$drawing->setAlphaType(AlphaType::ALPHA_TYPE_STRAIGHT);
$fileProperties = new FileProperties;
$fileProperties->setLocalUrl($this->bundleRelativeUrl($bundleRelativeFilename));
$image = new ImageTypeProperties;
$image->setDrawing($drawing);
$image->setFile($fileProperties);
$metadata = new Metadata;
$metadata->setFormat('JPG');
$media = new Media;
$media->setUuid($this->newUuid());
$media->setUrl($this->bundleRelativeUrl($bundleRelativeFilename));
$media->setMetadata($metadata);
$media->setImage($image);
$fill = new Fill;
$fill->setEnable(true);
$fill->setMedia($media);
$graphicsElement = new GraphicsElement;
$graphicsElement->setUuid($this->newUuid());
$graphicsElement->setName($elementName);
$graphicsElement->setBounds($this->fullFrameBounds($width, $height));
$graphicsElement->setOpacity(1.0);
$graphicsElement->setPath($this->rectanglePath());
$graphicsElement->setFill($fill);
$slideElement = new SlideElement;
$slideElement->setElement($graphicsElement);
$slideElement->setInfo(0);
return $slideElement;
}
/**
* Self-contained REPLACE: derive the per-cue plan from the Song's own
* FOREGROUND image media actions, then strip those actions and inject content
* elements. This is the method the export services call after
* ProFileGenerator::generate(). BACKGROUND image actions are left in place.
*/
public function replaceImageActionsWithElements(Song $song): void
{
$this->injectIntoSong($song, $this->planFromSong($song));
}
/**
* Convert each cue's FOREGROUND (uploaded) image media action into a
* full-frame content fill.media element appended on top of the generated
* text/content elements, and strip that foreground action. The BACKGROUND
* media action (service background / key-visual) is left untouched so it
* keeps rendering as a background layer beneath all slide content.
*
* $plan is an ordered list (one entry per cue, in cue order). Each entry:
* ['foreground' => ?string, 'foregroundName' => ?string]
* A null/absent foreground leaves the cue exactly as generated (text-only
* songs, key-visual-only slides, name-tag slides).
*
* @param array<int, array{foreground?: ?string, foregroundName?: ?string}> $plan
*/
public function injectIntoSong(Song $song, array $plan): void
{
$index = 0;
foreach ($song->getPresentation()->getCues() as $cue) {
$entry = $plan[$index] ?? [];
$index++;
$baseSlide = $this->baseSlideForCue($cue);
if ($baseSlide === null) {
continue;
}
$foreground = $entry['foreground'] ?? null;
if ($foreground === null || $foreground === '') {
// No foreground image: leave the cue (text elements, BACKGROUND
// media action, macro) exactly as the generator produced it.
continue;
}
$this->stripForegroundImageMediaActions($cue);
$elements = [];
foreach ($baseSlide->getElements() as $element) {
$elements[] = $element;
}
$elements[] = $this->mediaFillElement($foreground, $entry['foregroundName'] ?? 'Bild');
$baseSlide->setElements($elements);
}
}
/**
* Derive the injection plan from the FOREGROUND image media actions the
* generator added. BACKGROUND-layer image actions (service background /
* key-visual) are intentionally ignored so they remain media actions.
*
* @return array<int, array{foreground: ?string, foregroundName: ?string}>
*/
private function planFromSong(Song $song): array
{
$plan = [];
foreach ($song->getPresentation()->getCues() as $cue) {
$foreground = null;
$foregroundName = null;
foreach ($cue->getActions() as $action) {
if ($action->getType() !== ActionType::ACTION_TYPE_MEDIA) {
continue;
}
$mediaType = $action->getMedia();
$mediaElement = $mediaType?->getElement();
if ($mediaElement === null || ! $mediaElement->hasImage()) {
continue;
}
// Only FOREGROUND images become content elements. BACKGROUND-layer
// images (service background / key-visual) stay as media actions.
if ($mediaType->getLayerType() === LayerType::LAYER_TYPE_BACKGROUND) {
continue;
}
$filename = $mediaElement->getUrl()?->getAbsoluteString();
if ($filename === null || $filename === '') {
continue;
}
$foreground = $filename;
$foregroundName = $action->getName() !== '' ? $action->getName() : null;
}
$plan[] = [
'foreground' => $foreground,
'foregroundName' => $foregroundName,
];
}
return $plan;
}
/** Resolve the base slide via Cue → SLIDE action → presentation → base slide. */
private function baseSlideForCue(Cue $cue): ?BaseSlide
{
foreach ($cue->getActions() as $action) {
if ($action->getType() === ActionType::ACTION_TYPE_PRESENTATION_SLIDE) {
return $action->getSlide()?->getPresentation()?->getBaseSlide();
}
}
return null;
}
/**
* Remove only FOREGROUND-layer media actions whose element carries an image.
* BACKGROUND-layer image actions (service background / key-visual) are kept.
*/
private function stripForegroundImageMediaActions(Cue $cue): void
{
$kept = [];
$stripped = false;
foreach ($cue->getActions() as $action) {
if ($action->getType() === ActionType::ACTION_TYPE_MEDIA) {
$mediaType = $action->getMedia();
$mediaElement = $mediaType?->getElement();
if (
$mediaElement !== null
&& $mediaElement->hasImage()
&& $mediaType->getLayerType() !== LayerType::LAYER_TYPE_BACKGROUND
) {
$stripped = true;
continue;
}
}
$kept[] = $action;
}
if ($stripped) {
$cue->setActions($kept);
}
}
private function bundleRelativeUrl(string $filename): URL
{
$local = new LocalRelativePath;
$local->setRoot(LocalRelativePath\Root::ROOT_CURRENT_RESOURCE);
$local->setPath($filename);
$url = new URL;
$url->setAbsoluteString($filename);
$url->setLocal($local);
$url->setPlatform(UrlPlatform::PLATFORM_MACOS);
return $url;
}
private function fullFrameBounds(int $width, int $height): Rect
{
$origin = new Point;
$origin->setX(0);
$origin->setY(0);
$size = new Size;
$size->setWidth($width);
$size->setHeight($height);
$rect = new Rect;
$rect->setOrigin($origin);
$rect->setSize($size);
return $rect;
}
private function rectanglePath(): Path
{
$points = [];
foreach ([[0, 0], [1, 0], [1, 1], [0, 1]] as [$x, $y]) {
$point = new Point;
$point->setX((float) $x);
$point->setY((float) $y);
$bezier = new BezierPoint;
$bezier->setPoint($point);
$bezier->setQ0($point);
$bezier->setQ1($point);
$bezier->setCurved(false);
$points[] = $bezier;
}
$path = new Path;
$path->setClosed(true);
$path->setPoints($points);
$shape = new Shape;
$shape->setType(ShapeType::TYPE_RECTANGLE);
$path->setShape($shape);
return $path;
}
private function newUuid(): UUID
{
$bytes = random_bytes(16);
$bytes[6] = chr((ord($bytes[6]) & 0x0F) | 0x40);
$bytes[8] = chr((ord($bytes[8]) & 0x3F) | 0x80);
$hex = bin2hex($bytes);
$uuid = new UUID;
$uuid->setString(strtoupper(sprintf(
'%s-%s-%s-%s-%s',
substr($hex, 0, 8),
substr($hex, 8, 4),
substr($hex, 12, 4),
substr($hex, 16, 4),
substr($hex, 20, 12),
)));
return $uuid;
}
}

View file

@ -11,7 +11,7 @@
"repositories": [
{
"type": "vcs",
"url": "https://git.stadtmission-butzbach.de/public/propresenter7-php-lib.git"
"url": "https://git.stadtmission-butzbach.de/public/propresenter-php.git"
}
],
"require": {
@ -24,7 +24,7 @@
"laravel/sanctum": "^4.0",
"laravel/socialite": "^5.24",
"laravel/tinker": "^2.10.1",
"bussnet/propresenter7-php-lib": "dev-master",
"propresenter/parser": "dev-master",
"spatie/pdf-to-image": "^1.2",
"tightenco/ziggy": "^2.0"
},

102
composer.lock generated
View file

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "8ddc03457b69ce825f98a92c19a0204e",
"content-hash": "87837501106e784aa10ddd7743056cba",
"packages": [
{
"name": "5pm-hdh/churchtools-api",
@ -191,77 +191,6 @@
],
"time": "2026-02-10T14:33:43+00:00"
},
{
"name": "bussnet/propresenter7-php-lib",
"version": "dev-master",
"source": {
"type": "git",
"url": "https://git.stadtmission-butzbach.de/public/propresenter7-php-lib.git",
"reference": "01654209c699deaea267c9e11e95d97e79505234"
},
"require": {
"google/protobuf": "^4.0",
"php": "^8.4"
},
"require-dev": {
"phpunit/phpunit": "^11.0"
},
"default-branch": true,
"bin": [
"bin/parse-song.php",
"bin/parse-playlist.php"
],
"type": "library",
"autoload": {
"psr-4": {
"ProPresenter\\Parser\\": "src/",
"Rv\\Data\\": "generated/Rv/Data/",
"GPBMetadata\\": "generated/GPBMetadata/"
}
},
"autoload-dev": {
"psr-4": {
"ProPresenter\\Parser\\Tests\\": "tests/"
}
},
"scripts": {
"test": [
"phpunit"
],
"test:coverage": [
"phpunit --coverage-text"
]
},
"license": [
"MIT"
],
"authors": [
{
"name": "Thorsten Buss",
"role": "Developer"
}
],
"description": "PHP library to read, modify, and generate ProPresenter 7 files (.pro songs, .proplaylist, .probundle, themes, and global library files).",
"homepage": "https://github.com/bussnet/propresenter7-php-lib",
"keywords": [
"ccli",
"church",
"parser",
"presentation",
"pro-file",
"probundle",
"proplaylist",
"propresenter",
"propresenter7",
"protobuf",
"worship"
],
"support": {
"issues": "https://github.com/bussnet/propresenter7-php-lib/issues",
"source": "https://github.com/bussnet/propresenter7-php-lib"
},
"time": "2026-07-05T21:05:47+00:00"
},
{
"name": "carbonphp/carbon-doctrine-types",
"version": "3.2.0",
@ -3884,6 +3813,33 @@
],
"time": "2026-01-27T09:17:28+00:00"
},
{
"name": "propresenter/parser",
"version": "dev-master",
"source": {
"type": "git",
"url": "https://git.stadtmission-butzbach.de/public/propresenter-php.git",
"reference": "9e3e719806d8db3941444b8424fdd56b3b534aa8"
},
"require": {
"google/protobuf": "^4.0",
"php": "^8.4"
},
"require-dev": {
"phpunit/phpunit": "^11.0"
},
"default-branch": true,
"type": "library",
"autoload": {
"psr-4": {
"ProPresenter\\Parser\\": "src/",
"Rv\\Data\\": "generated/Rv/Data/",
"GPBMetadata\\": "generated/GPBMetadata/"
}
},
"description": "ProPresenter song file parser",
"time": "2026-05-03T19:40:09+00:00"
},
{
"name": "psr/clock",
"version": "1.0.0",
@ -10906,7 +10862,7 @@
"aliases": [],
"minimum-stability": "stable",
"stability-flags": {
"bussnet/propresenter7-php-lib": 20
"propresenter/parser": 20
},
"prefer-stable": true,
"prefer-lowest": false,

851
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -14,14 +14,10 @@
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
use ProPresenter\Parser\ProPlaylistReader;
use Rv\Data\Cue\CompletionActionType;
use Rv\Data\Cue\CompletionTargetType;
use Tests\Support\InspectsSlideFillMedia;
use Tests\TestCase;
final class AbspannExportTest extends TestCase
{
use InspectsSlideFillMedia;
use RefreshDatabase;
protected function setUp(): void
@ -57,62 +53,31 @@ public function test_abspann_is_last_item_with_keyvisual_first_then_information_
$slides = $this->allParserSlides($abspann);
$this->assertCount(3, $slides);
// First cue is the key-visual as a BACKGROUND media action (no content element).
// First cue is the key-visual (image-only background media).
$this->assertTrue($slides[0]->hasBackgroundMedia(), 'First Abspann cue must be the key-visual');
$this->assertSame('KEY_VISUAL.jpg', $slides[0]->getBackgroundMediaUrl());
$this->assertSame([], $this->fillMediaUrls($slides[0]), 'Key-visual cue has no content image element');
$this->assertFalse($this->hasForegroundImageMediaAction($slides[0]), 'No foreground media action may remain');
$this->assertFalse($slides[0]->hasMedia(), 'Key-visual cue has no foreground media');
$this->assertArrayHasKey('KEY_VISUAL.jpg', $playlist->getEmbeddedMediaFiles());
// Remaining cues are the information slides as content image elements, labelled by filename.
$this->assertNotEmpty($this->fillMediaUrls($slides[1]));
$this->assertNotContains('KEY_VISUAL.jpg', $this->fillMediaUrls($slides[1]));
// Remaining cues are the information slides (foreground media, labelled by filename).
$this->assertTrue($slides[1]->hasMedia());
$this->assertFalse($slides[1]->hasBackgroundMedia());
$this->assertFalse($this->hasImageMediaAction($slides[1]));
$this->assertSame('info1.jpg', $slides[1]->getLabel());
$this->assertNotEmpty($this->fillMediaUrls($slides[2]));
$this->assertTrue($slides[2]->hasMedia());
$this->assertSame('info2.jpg', $slides[2]->getLabel());
// The Abspann is a self-advancing loop on the live parser round-trip: every cue
// auto-advances after 10s and the last cue jumps back to the first ("auto repeat all").
$presentation = $abspann->getPresentation();
$cues = $presentation->getCues();
$this->assertCount(3, $cues);
foreach ($cues as $cue) {
$this->assertSame(
CompletionActionType::COMPLETION_ACTION_TYPE_AFTER_TIME,
$cue->getCompletionActionType(),
'Every Abspann cue must auto-advance after a fixed time',
);
$this->assertSame(10.0, $cue->getCompletionTime(), 'Auto-advance must fire after 10 seconds');
}
// Leading cues step to the next slide; the final cue loops back to the first.
$this->assertSame(CompletionTargetType::COMPLETION_TARGET_TYPE_NEXT, $cues[0]->getCompletionTargetType());
$this->assertSame(CompletionTargetType::COMPLETION_TARGET_TYPE_NEXT, $cues[1]->getCompletionTargetType());
$this->assertSame(
CompletionTargetType::COMPLETION_TARGET_TYPE_FIRST,
$cues[count($cues) - 1]->getCompletionTargetType(),
'The final Abspann cue must loop back to the first cue',
);
// The presentation carries the Dissolve transition (soft cross-fade between slides).
$this->assertTrue($presentation->hasTransition(), 'Abspann must use a slide transition');
$effect = $presentation->getTransition()->getEffect();
$this->assertNotNull($effect);
$this->assertSame('EC52A828-AD85-4602-B70C-1DEE7C904DB6', $effect->getRenderId());
$this->assertSame('Dissolve', $effect->getName());
$this->assertSame('Dissolves', $effect->getCategory());
$this->cleanupTempDir($result['temp_dir']);
}
/**
* Auto-advance (10s), loop-to-first and the Dissolve transition survive the disk
* round-trip and are asserted in
* test_abspann_is_last_item_with_keyvisual_first_then_information_slides. This test
* stays focused on the per-cue Abspann macro assignment.
* Auto-advance (10s), loop-to-first and the Dissolve transition are emitted into
* the slideData by addAbspannPresentation and are covered by the parser repo's
* in-memory ProFileGeneratorCompletionTest. They are intentionally NOT asserted
* here: the parser's generated protobuf descriptor does not (yet) register the
* Cue.completion_* and Presentation.transition fields, so serializeToString()
* drops them on the disk round-trip that every export performs. Once the parser
* stubs are regenerated these become observable on the exported .proplaylist and
* this note can be replaced with real assertions.
*/
public function test_abspann_cues_carry_configured_abspann_macro(): void
{

View file

@ -9,7 +9,6 @@
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
use ProPresenter\Parser\ProBundleReader;
use Tests\Support\InspectsSlideFillMedia;
use Tests\TestCase;
/**
@ -20,7 +19,6 @@
*/
final class ExportBundleRelativeMediaTest extends TestCase
{
use InspectsSlideFillMedia;
use RefreshDatabase;
protected function setUp(): void
@ -126,17 +124,15 @@ public function test_probundle_export_referenziert_vordergrund_medien_bundle_rel
$bundle = ProBundleReader::read($bundlePath);
$slides = $bundle->getSong()->getSlides();
// The foreground image is now a content fill.media element, not a media ACTION.
$mediaSlides = array_filter($slides, fn ($slide) => $this->fillMediaUrls($slide) !== []);
$this->assertNotEmpty($mediaSlides, 'Expected a foreground-image content slide in the bundle.');
$mediaSlides = array_filter($slides, fn ($slide) => $slide->hasMedia());
$this->assertNotEmpty($mediaSlides, 'Expected a foreground-media slide in the bundle.');
foreach ($mediaSlides as $slide) {
$this->assertFalse($this->hasImageMediaAction($slide), 'No image media ACTION may remain.');
foreach ($this->fillMediaUrls($slide) as $url) {
$url = $slide->getMediaUrl();
$this->assertNotNull($url);
// Bundle-relative resources use a bare basename (no absolute path / no slash).
$this->assertStringNotContainsString('/', $url, 'Foreground media URL must be bundle-relative (basename only).');
}
}
@unlink($bundlePath);
}

View file

@ -1,221 +0,0 @@
<?php
namespace Tests\Feature;
use App\Models\ExportProFile;
use App\Models\Label;
use App\Models\Service;
use App\Models\ServiceSong;
use App\Models\Song;
use App\Services\PlaylistExportService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
use ProPresenter\Parser\ProFileGenerator;
use ProPresenter\Parser\ProPlaylistReader;
use Tests\TestCase;
use ZipArchive;
/**
* Regression cover for the "empty keyword/prefix show" bug: a .probundle uploaded
* as a keyword/prefix/postfix export is stored by Laravel under a MIME-guessed
* .zip extension. Branching on the stored path then misses the probundle branch
* and embeds the raw ZIP bytes as a broken .pro (ProPresenter shows 0 slides).
*
* These tests use REAL, slide-bearing .pro bytes (generated by the parser) wrapped
* in a real ZIP dummy string bytes are exactly what hid the original bug.
*/
final class ExportProFileProbundleEmbedTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
Storage::fake('local');
Storage::fake('public');
}
/** Build real, parseable .pro bytes with the given groups/arrangements. */
private function realProBytes(string $name, array $groups, array $arrangements): string
{
$tmp = tempnam(sys_get_temp_dir(), 'fixture-pro-').'.pro';
ProFileGenerator::generateAndWrite($tmp, $name, $groups, $arrangements);
$bytes = file_get_contents($tmp);
@unlink($tmp);
return $bytes;
}
/** Wrap real .pro bytes (+ optional media) into a real .probundle ZIP, return the archive bytes. */
private function probundleBytes(string $proBytes, array $mediaFiles = []): string
{
$tmp = tempnam(sys_get_temp_dir(), 'fixture-bundle-').'.probundle';
$zip = new ZipArchive;
$zip->open($tmp, ZipArchive::CREATE | ZipArchive::OVERWRITE);
$zip->addFromString('presentation.pro', $proBytes);
foreach ($mediaFiles as $name => $content) {
$zip->addFromString($name, $content);
}
$zip->close();
$bytes = file_get_contents($tmp);
@unlink($tmp);
return $bytes;
}
private function createSongWithContent(string $title): Song
{
$song = Song::create([
'title' => $title,
'ccli_id' => fake()->unique()->numerify('#####'),
'author' => 'Test Author',
'copyright_text' => 'Test Publisher',
]);
$label = Label::firstOrCreate(
['name' => 'Verse - '.$title],
['color' => '#2196F3'],
);
$section = $song->sections()->create(['label_id' => $label->id, 'order' => 0]);
$section->slides()->create(['order' => 0, 'text_content' => 'Erste Zeile']);
$arrangement = $song->arrangements()->create(['name' => 'normal', 'is_default' => true]);
$arrangement->arrangementSections()->create(['song_section_id' => $section->id, 'order' => 0]);
return $song;
}
private function allParserSlides(\ProPresenter\Parser\Song $parserSong): array
{
$slides = [];
foreach ($parserSong->getGroups() as $group) {
foreach ($parserSong->getSlidesForGroup($group) as $slide) {
$slides[] = $slide;
}
}
return $slides;
}
private function cleanupTempDir(string $dir): void
{
if (! is_dir($dir)) {
return;
}
$items = scandir($dir);
if ($items === false) {
return;
}
foreach ($items as $item) {
if ($item === '.' || $item === '..') {
continue;
}
$path = $dir.'/'.$item;
is_dir($path) ? $this->cleanupTempDir($path) : unlink($path);
}
rmdir($dir);
}
public function test_probundle_mis_stored_as_zip_wird_mit_folien_injiziert(): void
{
$service = Service::factory()->create(['title' => 'Bundle Service', 'date' => now()]);
$song = $this->createSongWithContent('Lied');
ServiceSong::create([
'service_id' => $service->id,
'song_id' => $song->id,
'cts_song_name' => 'Lied',
'order' => 1,
]);
// Real inner .pro with a group of 2 content slides.
$proBytes = $this->realProBytes('intro', [
['name' => 'Verse', 'color' => [0.5, 0.5, 0.5, 1.0], 'slides' => [
['text' => 'Folie eins'],
['text' => 'Folie zwei'],
]],
], [['name' => 'normal', 'groupNames' => ['Verse']]]);
$bundleBytes = $this->probundleBytes($proBytes, ['hintergrund.jpg' => 'jpg-bytes']);
// Reproduce the mis-stored state: original name .probundle, stored path .zip.
$storedPath = 'export-pro-files/prefix/'.\Illuminate\Support\Str::random(40).'.zip';
Storage::disk('local')->put($storedPath, $bundleBytes);
ExportProFile::create([
'type' => 'prefix',
'original_name' => 'intro.probundle',
'stored_path' => $storedPath,
'order' => 1,
]);
$result = app(PlaylistExportService::class)->generatePlaylist($service);
$playlist = ProPlaylistReader::read($result['path']);
$entryNames = array_map(fn ($e) => $e->getName(), $playlist->getEntries());
$this->assertContains('intro', $entryNames, 'Prefix bundle presentation must be injected');
$embeddedSong = $playlist->getEmbeddedSong('PREFIX_1_intro.pro');
$this->assertNotNull($embeddedSong, 'Inner .pro must be embedded, not the raw ZIP');
$this->assertCount(2, $this->allParserSlides($embeddedSong), 'Inner .pro must keep its 2 content slides');
$embeddedFiles = $playlist->getEmbeddedFiles();
$this->assertArrayHasKey('hintergrund.jpg', $embeddedFiles, 'Bundle media must be embedded');
$this->assertSame('jpg-bytes', $embeddedFiles['hintergrund.jpg']);
$this->assertSame([], $result['warnings'], 'A slide-bearing bundle must not be skipped');
$this->cleanupTempDir($result['temp_dir']);
}
public function test_probundle_ohne_folien_wird_uebersprungen_mit_warnung(): void
{
$service = Service::factory()->create(['title' => 'Leer Service', 'date' => now()]);
$song = $this->createSongWithContent('Lied');
ServiceSong::create([
'service_id' => $service->id,
'song_id' => $song->id,
'cts_song_name' => 'Lied',
'order' => 1,
]);
// Real inner .pro that parses fine but has a group with ZERO slides.
$proBytes = $this->realProBytes('leer', [
['name' => 'Verse', 'color' => [0.5, 0.5, 0.5, 1.0], 'slides' => []],
], [['name' => 'normal', 'groupNames' => ['Verse']]]);
$bundleBytes = $this->probundleBytes($proBytes);
$storedPath = 'export-pro-files/prefix/'.\Illuminate\Support\Str::random(40).'.zip';
Storage::disk('local')->put($storedPath, $bundleBytes);
ExportProFile::create([
'type' => 'prefix',
'original_name' => 'leer.probundle',
'stored_path' => $storedPath,
'order' => 1,
]);
$result = app(PlaylistExportService::class)->generatePlaylist($service);
$playlist = ProPlaylistReader::read($result['path']);
$entryNames = array_map(fn ($e) => $e->getName(), $playlist->getEntries());
$this->assertNotContains('leer', $entryNames, 'A contentless bundle must not be injected');
$this->assertNull($playlist->getEmbeddedSong('PREFIX_1_leer.pro'), 'A contentless bundle must not be embedded');
$this->assertContains(
"Export-Datei 'leer.probundle' übersprungen: keine Folien gefunden.",
$result['warnings'],
'A contentless bundle must be skipped with a German warning',
);
// The valid song is still exported.
$this->assertContains('Lied', $entryNames);
$this->cleanupTempDir($result['temp_dir']);
}
}

View file

@ -13,12 +13,10 @@
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
use ProPresenter\Parser\ProPlaylistReader;
use Tests\Support\InspectsSlideFillMedia;
use Tests\TestCase;
final class FullPlaylistExportTest extends TestCase
{
use InspectsSlideFillMedia;
use RefreshDatabase;
protected function setUp(): void
@ -116,12 +114,8 @@ public function test_full_service_playlist_includes_all_features_in_correct_orde
$songSlides = $this->allParserSlides($songParser);
$this->assertNotEmpty($songSlides);
foreach ($songSlides as $slide) {
// Service background stays a BACKGROUND media action behind the song text.
$this->assertTrue($slide->hasBackgroundMedia(), 'Song slide must have background action');
$this->assertTrue($slide->hasBackgroundMedia(), 'Song slide must have background media');
$this->assertSame('BACKGROUND.jpg', $slide->getBackgroundMediaUrl());
$this->assertTrue($this->elementHasTextAt($slide, 0));
$this->assertSame([], $this->fillMediaUrls($slide));
$this->assertFalse($this->hasForegroundImageMediaAction($slide));
}
$embeddedMedia = $playlist->getEmbeddedMediaFiles();
@ -135,13 +129,7 @@ public function test_full_service_playlist_includes_all_features_in_correct_orde
$sermonSlides = $this->allParserSlides($sermonParser);
$this->assertCount(2, $sermonSlides);
foreach ($sermonSlides as $slide) {
// Uploaded sermon image is a content element with the service background
// behind it as a BACKGROUND media action (background is NOT a content element).
$this->assertTrue($slide->hasBackgroundMedia(), 'Sermon slide must have background action');
$this->assertSame('BACKGROUND.jpg', $slide->getBackgroundMediaUrl());
$this->assertNotEmpty($this->fillMediaUrls($slide));
$this->assertNotContains('BACKGROUND.jpg', $this->fillMediaUrls($slide));
$this->assertFalse($this->hasForegroundImageMediaAction($slide));
$this->assertTrue($slide->hasBackgroundMedia(), 'Sermon slide must have background media');
}
$this->assertSame($slideCountBefore, Slide::count());

View file

@ -12,12 +12,10 @@
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
use ProPresenter\Parser\ProPlaylistReader;
use Tests\Support\InspectsSlideFillMedia;
use Tests\TestCase;
final class KeyVisualFallbackTest extends TestCase
{
use InspectsSlideFillMedia;
use RefreshDatabase;
protected function setUp(): void
@ -155,11 +153,7 @@ public function test_sermon_agenda_item_with_uploaded_slides_prepends_keyvisual_
$slides = $this->allParserSlides($sermonSong);
$this->assertCount(1, $slides);
// Sermon slide is a foreground content image; no service background configured → no background layer.
$this->assertFalse($slides[0]->hasBackgroundMedia());
$this->assertNotContains('BACKGROUND.jpg', $this->fillMediaUrls($slides[0]));
$this->assertNotEmpty($this->fillMediaUrls($slides[0]));
$this->assertFalse($this->hasImageMediaAction($slides[0]));
$this->cleanupTempDir($result['temp_dir']);
}

View file

@ -14,12 +14,10 @@
use Illuminate\Support\Facades\Storage;
use ProPresenter\Parser\PlaylistArchive;
use ProPresenter\Parser\ProPlaylistReader;
use Tests\Support\InspectsSlideFillMedia;
use Tests\TestCase;
final class PlaylistSequenceTest extends TestCase
{
use InspectsSlideFillMedia;
use RefreshDatabase;
protected function setUp(): void
@ -68,18 +66,12 @@ public function test_sermon_sequence_is_keyvisual_preacher_nametag_then_uploaded
$introSlides = $this->slidesForEntry($playlist, $entries[$offset]);
$this->assertCount(2, $introSlides);
// Key-visual is a BACKGROUND media action on both intro cues (no content element).
$this->assertTrue($introSlides[0]->hasBackgroundMedia());
$this->assertSame('KEY_VISUAL.jpg', $introSlides[0]->getBackgroundMediaUrl());
$this->assertSame([], $this->fillMediaUrls($introSlides[0]));
$this->assertFalse($this->hasForegroundImageMediaAction($introSlides[0]));
$this->assertArrayHasKey('KEY_VISUAL.jpg', $playlist->getEmbeddedMediaFiles());
// Second cue: key-visual as a BACKGROUND media action with the name-tag text element above it.
$this->assertTrue($introSlides[1]->hasBackgroundMedia());
$this->assertSame('KEY_VISUAL.jpg', $introSlides[1]->getBackgroundMediaUrl());
$this->assertTrue($this->elementHasTextAt($introSlides[1], 0));
$this->assertFalse($this->hasForegroundImageMediaAction($introSlides[1]));
// Name and role are split: the name is the main (\fs84) run and the
// role is a separate smaller, non-bold (\b0\fs50) subtitle run.
[$name, $subtitle] = $this->nameTagNameAndSubtitle($introSlides[1]);
@ -297,14 +289,10 @@ public function test_sermon_item_without_uploaded_slides_still_emits_intro_and_n
// The intro .pro carries both cues: key-visual alone + key-visual with name tag.
$introSlides = $this->slidesForEntry($playlist, $entries[0]);
$this->assertCount(2, $introSlides);
// Key-visual is a BACKGROUND media action on both intro cues (no content element).
$this->assertTrue($introSlides[0]->hasBackgroundMedia());
$this->assertSame('KEY_VISUAL.jpg', $introSlides[0]->getBackgroundMediaUrl());
$this->assertSame([], $this->fillMediaUrls($introSlides[0]));
$this->assertTrue($introSlides[1]->hasBackgroundMedia());
$this->assertSame('KEY_VISUAL.jpg', $introSlides[1]->getBackgroundMediaUrl());
$this->assertTrue($this->elementHasTextAt($introSlides[1], 0));
$this->assertFalse($this->hasForegroundImageMediaAction($introSlides[1]));
[$name, $subtitle] = $this->nameTagNameAndSubtitle($introSlides[1]);
$this->assertSame('Erika Predigt', $name);
$this->assertSame('Predigt', $subtitle);

View file

@ -1,266 +0,0 @@
<?php
namespace Tests\Feature;
use App\Models\Macro;
use App\Models\MacroAssignment;
use App\Models\Service;
use App\Models\ServiceAgendaItem;
use App\Models\ServiceSong;
use App\Models\Slide;
use App\Services\PlaylistExportService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
use ProPresenter\Parser\ProPlaylistReader;
use Tests\TestCase;
final class PlaylistSermonMacroTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
Storage::fake('public');
}
/**
* Agenda-based sermon export must attach position macros (first_slide /
* last_slide / all_slides) for part_type='sermon' onto the sermon slides.
* The parser stores only one macro per slide (the app keeps $macros[0]); the
* lowest `order` among the matching assignments wins, so first/last override
* all_slides on the first/last slide while the middle slide keeps all_slides.
*/
public function test_playlist_sermon_slides_carry_position_macros(): void
{
Storage::disk('public')->put('slides/sermon-1.jpg', 'sermon-one');
Storage::disk('public')->put('slides/sermon-2.jpg', 'sermon-two');
Storage::disk('public')->put('slides/sermon-3.jpg', 'sermon-three');
$macroFirst = Macro::factory()->create(['name' => 'Predigt Erste']);
$macroLast = Macro::factory()->create(['name' => 'Predigt Letzte']);
$macroAll = Macro::factory()->create(['name' => 'Predigt Alle']);
// Lower `order` wins when several assignments match one slide.
MacroAssignment::create(['part_type' => 'sermon', 'macro_id' => $macroFirst->id, 'position' => 'first_slide', 'order' => 0]);
MacroAssignment::create(['part_type' => 'sermon', 'macro_id' => $macroLast->id, 'position' => 'last_slide', 'order' => 1]);
MacroAssignment::create(['part_type' => 'sermon', 'macro_id' => $macroAll->id, 'position' => 'all_slides', 'order' => 2]);
$service = Service::factory()->create([
'title' => 'Predigt Macro Service',
'date' => now(),
]);
// A song with real (DB-driven) content guarantees the export always has at
// least one content presentation, so it never hits the "Keine Songs mit
// Inhalt zum Exportieren gefunden" guard regardless of test ordering or
// slide-file resolution. `responsible => []` pins the agenda items so the
// factory's random responsible data cannot inject stray name-tag slides.
$song = $this->createSongWithContent('Startlied');
$serviceSong = ServiceSong::create([
'service_id' => $service->id,
'song_id' => $song->id,
'cts_song_name' => 'Startlied',
'order' => 1,
]);
ServiceAgendaItem::factory()->create([
'service_id' => $service->id,
'title' => 'Startlied',
'service_song_id' => $serviceSong->id,
'sort_order' => 1,
'is_before_event' => false,
'responsible' => [],
]);
$sermonItem = ServiceAgendaItem::factory()->create([
'service_id' => $service->id,
'title' => 'Predigt',
'service_song_id' => null,
'sort_order' => 2,
'is_before_event' => false,
'responsible' => [],
]);
$this->createSermonSlide($service, $sermonItem, 'sermon-1.jpg', 0);
$this->createSermonSlide($service, $sermonItem, 'sermon-2.jpg', 1);
$this->createSermonSlide($service, $sermonItem, 'sermon-3.jpg', 2);
$result = app(PlaylistExportService::class)->generatePlaylist($service);
$playlist = ProPlaylistReader::read($result['path']);
$sermon = $playlist->getEmbeddedSong('Predigt.pro');
$this->assertNotNull($sermon, 'Embedded sermon .pro missing');
$slides = $this->allParserSlides($sermon);
$this->assertCount(3, $slides);
foreach ($slides as $slide) {
$this->assertTrue($slide->hasMacro(), 'Every sermon slide must carry a macro (all_slides guarantees at least one)');
}
// First slide: first_slide wins over all_slides (lower order).
$this->assertSame('Predigt Erste', $slides[0]->getMacroName());
$this->assertSame($macroFirst->uuid, $slides[0]->getMacroUuid());
// Middle slide: only all_slides matches.
$this->assertSame('Predigt Alle', $slides[1]->getMacroName());
// Last slide: last_slide wins over all_slides (lower order).
$this->assertSame('Predigt Letzte', $slides[2]->getMacroName());
$this->assertSame($macroLast->uuid, $slides[2]->getMacroUuid());
$this->cleanupTempDir($result['temp_dir']);
}
/**
* The legacy (no-agenda) export path emits Information, Moderation and Sermon
* presentations with their own explicit part_type. Each must attach the
* all_slides macro registered for that part_type onto every slide.
*/
public function test_legacy_information_moderation_sermon_slides_carry_macros(): void
{
Storage::disk('public')->put('slides/info-1.jpg', 'info-one');
Storage::disk('public')->put('slides/info-2.jpg', 'info-two');
Storage::disk('public')->put('slides/mod-1.jpg', 'mod-one');
Storage::disk('public')->put('slides/mod-2.jpg', 'mod-two');
Storage::disk('public')->put('slides/sermon-1.jpg', 'sermon-one');
Storage::disk('public')->put('slides/sermon-2.jpg', 'sermon-two');
$infoMacro = Macro::factory()->create(['name' => 'Info Macro']);
$modMacro = Macro::factory()->create(['name' => 'Moderation Macro']);
$sermonMacro = Macro::factory()->create(['name' => 'Predigt Macro']);
MacroAssignment::create(['part_type' => 'information', 'macro_id' => $infoMacro->id, 'position' => 'all_slides', 'order' => 0]);
MacroAssignment::create(['part_type' => 'moderation', 'macro_id' => $modMacro->id, 'position' => 'all_slides', 'order' => 0]);
MacroAssignment::create(['part_type' => 'sermon', 'macro_id' => $sermonMacro->id, 'position' => 'all_slides', 'order' => 0]);
// No agenda items => the export uses the legacy block-ordered path.
$service = Service::factory()->create([
'title' => 'Legacy Macro Service',
'date' => now(),
]);
$this->createInfoSlide('info-1.jpg', 0);
$this->createInfoSlide('info-2.jpg', 1);
$this->createServiceSlide($service, 'moderation', 'mod-1.jpg', 0);
$this->createServiceSlide($service, 'moderation', 'mod-2.jpg', 1);
$this->createServiceSlide($service, 'sermon', 'sermon-1.jpg', 0);
$this->createServiceSlide($service, 'sermon', 'sermon-2.jpg', 1);
$result = app(PlaylistExportService::class)->generatePlaylist($service);
$playlist = ProPlaylistReader::read($result['path']);
$this->assertAllSlidesHaveMacro($playlist->getEmbeddedSong('Informationen.pro'), 'Info Macro');
$this->assertAllSlidesHaveMacro($playlist->getEmbeddedSong('Moderation.pro'), 'Moderation Macro');
$this->assertAllSlidesHaveMacro($playlist->getEmbeddedSong('Predigt.pro'), 'Predigt Macro');
$this->cleanupTempDir($result['temp_dir']);
}
private function assertAllSlidesHaveMacro(?\ProPresenter\Parser\Song $song, string $macroName): void
{
$this->assertNotNull($song, "Embedded .pro for macro '{$macroName}' missing");
$slides = $this->allParserSlides($song);
$this->assertCount(2, $slides);
foreach ($slides as $slide) {
$this->assertTrue($slide->hasMacro(), "Slide for '{$macroName}' must carry a macro");
$this->assertSame($macroName, $slide->getMacroName());
}
}
private function createSermonSlide(Service $service, ServiceAgendaItem $agendaItem, string $filename, int $sortOrder): Slide
{
return Slide::factory()->create([
'service_id' => $service->id,
'service_agenda_item_id' => $agendaItem->id,
'type' => 'sermon',
'original_filename' => $filename,
'stored_filename' => 'slides/'.$filename,
'sort_order' => $sortOrder,
]);
}
private function createInfoSlide(string $filename, int $sortOrder): Slide
{
return Slide::factory()->create([
'service_id' => null,
'type' => 'information',
'original_filename' => $filename,
'stored_filename' => 'slides/'.$filename,
'sort_order' => $sortOrder,
'expire_date' => null,
'uploaded_at' => now()->subDay(),
]);
}
private function createServiceSlide(Service $service, string $type, string $filename, int $sortOrder): Slide
{
return Slide::factory()->create([
'service_id' => $service->id,
'type' => $type,
'original_filename' => $filename,
'stored_filename' => 'slides/'.$filename,
'sort_order' => $sortOrder,
]);
}
private function createSongWithContent(string $title): \App\Models\Song
{
$song = \App\Models\Song::create([
'title' => $title,
'ccli_id' => fake()->unique()->numerify('#####'),
'author' => 'Test Author',
'copyright_text' => 'Test Publisher',
]);
$label = \App\Models\Label::firstOrCreate(
['name' => 'Verse - '.$title],
['color' => '#2196F3'],
);
$section = $song->sections()->create(['label_id' => $label->id, 'order' => 0]);
$section->slides()->create(['order' => 0, 'text_content' => 'Erste Zeile']);
$arrangement = $song->arrangements()->create(['name' => 'normal', 'is_default' => true]);
$arrangement->arrangementSections()->create(['song_section_id' => $section->id, 'order' => 0]);
return $song;
}
private function allParserSlides(\ProPresenter\Parser\Song $parserSong): array
{
$slides = [];
foreach ($parserSong->getGroups() as $group) {
foreach ($parserSong->getSlidesForGroup($group) as $slide) {
$slides[] = $slide;
}
}
return $slides;
}
private function cleanupTempDir(string $dir): void
{
if (! is_dir($dir)) {
return;
}
$items = scandir($dir);
if ($items === false) {
return;
}
foreach ($items as $item) {
if ($item === '.' || $item === '..') {
continue;
}
$path = $dir.'/'.$item;
is_dir($path) ? $this->cleanupTempDir($path) : unlink($path);
}
rmdir($dir);
}
}

View file

@ -18,12 +18,10 @@
use Illuminate\Support\Facades\Storage;
use ProPresenter\Parser\ProBundleReader;
use ProPresenter\Parser\ProPlaylistReader;
use Tests\Support\InspectsSlideFillMedia;
use Tests\TestCase;
final class ProFileExportTest extends TestCase
{
use InspectsSlideFillMedia;
use RefreshDatabase;
private function createSongWithContent(): Song
@ -294,13 +292,9 @@ public function test_export_mit_service_background_enthaelt_background_auf_allen
$this->assertNotEmpty($slides);
foreach ($slides as $slide) {
// Service background stays a BACKGROUND media action behind the song text.
$this->assertTrue($slide->hasBackgroundMedia());
$this->assertSame('BACKGROUND.jpg', $slide->getBackgroundMediaUrl());
// Text is a content element; no image content element, no foreground action.
$this->assertTrue($this->elementHasTextAt($slide, 0));
$this->assertSame([], $this->fillMediaUrls($slide));
$this->assertFalse($this->hasForegroundImageMediaAction($slide));
$this->assertSame('JPG', $slide->getBackgroundMediaFormat());
}
}
@ -367,10 +361,7 @@ public function test_export_ohne_background_enthaelt_keine_background_actions():
$parserSong = app(ProExportService::class)->generateParserSong($song, $service);
foreach ($this->allParserSlides($parserSong) as $slide) {
// No background configured → no background fill.media element and no media ACTION.
$this->assertFalse($slide->hasBackgroundMedia());
$this->assertNotContains('BACKGROUND.jpg', $this->fillMediaUrls($slide));
$this->assertFalse($this->hasImageMediaAction($slide));
}
}
@ -407,18 +398,9 @@ public function test_sermon_export_ueberspringt_background_bei_full_cover_folien
$slides = $this->allParserSlides($bundle->getSong());
$this->assertCount(2, $slides);
// contain slide: the uploaded image is a content element with the service
// background behind it as a BACKGROUND media action.
$this->assertTrue($slides[0]->hasBackgroundMedia());
$this->assertSame('BACKGROUND.jpg', $slides[0]->getBackgroundMediaUrl());
$this->assertSame('contain.jpg', $this->fillMediaUrlAt($slides[0], 0));
$this->assertTrue($this->isFullFrameAt($slides[0], 0));
$this->assertFalse($this->hasForegroundImageMediaAction($slides[0]));
// cover slide: uploaded content element only, no background layer.
$this->assertFalse($slides[1]->hasBackgroundMedia());
$this->assertNotContains('BACKGROUND.jpg', $this->fillMediaUrls($slides[1]));
$this->assertSame('cover.jpg', $this->fillMediaUrlAt($slides[1], 0));
$this->assertFalse($this->hasForegroundImageMediaAction($slides[1]));
$this->assertTrue($bundle->hasMediaFile('BACKGROUND.jpg'), 'Background image must be embedded under fixed name');
$this->assertSame('background-image', $bundle->getMediaFile('BACKGROUND.jpg'));
@ -455,9 +437,7 @@ public function test_information_und_moderation_exports_erhalten_keinen_backgrou
$bundlePath = app(ProBundleExportService::class)->generateBundle($service, $blockType);
foreach ($this->allParserSlides(ProBundleReader::read($bundlePath)->getSong()) as $slide) {
// Info/moderation slides carry only the foreground image element, never a background layer.
$this->assertFalse($slide->hasBackgroundMedia());
$this->assertNotContains('BACKGROUND.jpg', $this->fillMediaUrls($slide));
}
@unlink($bundlePath);
@ -524,21 +504,12 @@ public function test_playlist_export_setzt_background_auf_sermon_folien_und_nich
foreach ($this->allParserSlides($informationSong) as $slide) {
$this->assertFalse($slide->hasBackgroundMedia());
$this->assertNotContains('BACKGROUND.jpg', $this->fillMediaUrls($slide));
}
$sermonSlides = $this->allParserSlides($sermonSong);
$this->assertCount(2, $sermonSlides);
// contain slide: uploaded content element with the service background behind it
// as a BACKGROUND media action (background is NOT a content element).
$this->assertTrue($sermonSlides[0]->hasBackgroundMedia());
$this->assertSame('BACKGROUND.jpg', $sermonSlides[0]->getBackgroundMediaUrl());
$this->assertNotEmpty($this->fillMediaUrls($sermonSlides[0]));
$this->assertNotContains('BACKGROUND.jpg', $this->fillMediaUrls($sermonSlides[0]));
$this->assertFalse($this->hasForegroundImageMediaAction($sermonSlides[0]));
// cover slide: uploaded content element only, no background layer.
$this->assertFalse($sermonSlides[1]->hasBackgroundMedia());
$this->assertNotContains('BACKGROUND.jpg', $this->fillMediaUrls($sermonSlides[1]));
$this->cleanupTempDir($result['temp_dir']);
}

View file

@ -1,220 +0,0 @@
<?php
namespace Tests\Feature;
use App\Models\Label;
use App\Models\Service;
use App\Models\Song;
use App\Services\ProExportService;
use App\Services\SlideMediaElementBuilder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
use ProPresenter\Parser\ProFileGenerator;
use ProPresenter\Parser\ProFileReader;
use ProPresenter\Parser\Slide as ParserSlide;
use Tests\Support\InspectsSlideFillMedia;
use Tests\TestCase;
/**
* Verifies the COMBINED image rule enforced by SlideMediaElementBuilder:
*
* 1. Song slide text element + BACKGROUND media action, NO image element.
* 2. Uploaded content slide full-frame fill.media content element, NO foreground
* action (a service background, if set, stays a
* BACKGROUND media action behind the element).
* 3. Key-visual slide BACKGROUND media action only, NO content element.
* 4. Name-tag slide text element + BACKGROUND media action + macro.
*/
final class SlideMediaElementTest extends TestCase
{
use InspectsSlideFillMedia;
use RefreshDatabase;
private function bgData(string $name = 'BACKGROUND.jpg'): array
{
return ['path' => $name, 'format' => 'JPG', 'width' => 1920, 'height' => 1080, 'bundleRelative' => true];
}
/** Generate a one-slide song and run the production builder over it. */
private function build(array $slideData, string $name = 'Test'): ParserSlide
{
$groups = [['name' => 'Gruppe', 'color' => [0, 0, 0, 1], 'slides' => [$slideData]]];
$arrangements = [['name' => 'normal', 'groupNames' => ['Gruppe']]];
$song = ProFileGenerator::generate($name, $groups, $arrangements);
app(SlideMediaElementBuilder::class)->replaceImageActionsWithElements($song);
return $song->getSlides()[0];
}
public function test_song_slide_keeps_background_action_and_text_without_image_element(): void
{
$groups = [['name' => 'Gruppe', 'color' => [0, 0, 0, 1], 'slides' => [[
'text' => 'Textzeile',
'background' => $this->bgData(),
]]]];
$arrangements = [['name' => 'normal', 'groupNames' => ['Gruppe']]];
$song = ProFileGenerator::generate('Song', $groups, $arrangements);
// Sanity: the generator emits a BACKGROUND media action before conversion.
$this->assertTrue($song->getSlides()[0]->hasBackgroundMedia());
app(SlideMediaElementBuilder::class)->replaceImageActionsWithElements($song);
$slide = $song->getSlides()[0];
// Background stays a BACKGROUND media action (NOT converted to an element).
$this->assertTrue($slide->hasBackgroundMedia());
$this->assertSame('BACKGROUND.jpg', $slide->getBackgroundMediaUrl());
// Text element preserved; no content fill.media element exists.
$this->assertTrue($this->elementHasTextAt($slide, 0));
$this->assertSame([], $this->fillMediaUrls($slide));
// No foreground image action.
$this->assertFalse($this->hasForegroundImageMediaAction($slide));
$this->assertFalse($slide->hasMedia());
}
public function test_uploaded_content_slide_becomes_full_frame_element_without_foreground_action(): void
{
// Production uploaded-slide data: media + format + label + bundleRelative (no text).
$slide = $this->build([
'media' => 'UPLOAD.jpg',
'format' => 'JPG',
'label' => 'upload.jpg',
'bundleRelative' => true,
]);
// A single full-frame content fill.media element, no text.
$this->assertCount(1, $this->baseSlideElements($slide));
$this->assertSame('UPLOAD.jpg', $this->fillMediaUrlAt($slide, 0));
$this->assertTrue($this->isFullFrameAt($slide, 0));
$this->assertFalse($this->elementHasTextAt($slide, 0));
// No foreground media action remains; no background configured at all.
$this->assertFalse($this->hasForegroundImageMediaAction($slide));
$this->assertFalse($slide->hasBackgroundMedia());
$this->assertFalse($this->hasImageMediaAction($slide));
}
public function test_uploaded_content_slide_with_service_background_keeps_background_action_behind_element(): void
{
$slide = $this->build([
'media' => 'UPLOAD.jpg',
'format' => 'JPG',
'label' => 'upload.jpg',
'bundleRelative' => true,
'background' => $this->bgData(),
]);
// Uploaded image is a full-frame content element on top.
$this->assertSame('UPLOAD.jpg', $this->fillMediaUrlAt($slide, 0));
$this->assertTrue($this->isFullFrameAt($slide, 0));
$this->assertFalse($this->hasForegroundImageMediaAction($slide));
// Service background stays a BACKGROUND media action behind the element.
$this->assertTrue($slide->hasBackgroundMedia());
$this->assertSame('BACKGROUND.jpg', $slide->getBackgroundMediaUrl());
}
public function test_key_visual_slide_is_background_action_only_without_content_element(): void
{
// Production key-visual slide data: imageOnly + background.
$slide = $this->build([
'imageOnly' => true,
'background' => $this->bgData('KEY_VISUAL.jpg'),
]);
// Key-visual is a BACKGROUND media action, not a content element.
$this->assertTrue($slide->hasBackgroundMedia());
$this->assertSame('KEY_VISUAL.jpg', $slide->getBackgroundMediaUrl());
// No content fill.media element and no foreground action.
$this->assertSame([], $this->fillMediaUrls($slide));
$this->assertFalse($this->hasForegroundImageMediaAction($slide));
}
public function test_name_tag_slide_keeps_text_background_action_and_macro(): void
{
$slide = $this->build([
'text' => 'Erika Predigt',
'subtitle' => 'Predigt',
'background' => $this->bgData('KEY_VISUAL.jpg'),
'macro' => [
'name' => 'Namenseinblender',
'uuid' => '11111111-1111-4111-8111-111111111111',
'collectionName' => '--MAIN--',
'collectionUuid' => null,
],
]);
// Text element preserved (name-tag text sits above the background).
$this->assertTrue($this->elementHasTextAt($slide, 0));
$this->assertSame([], $this->fillMediaUrls($slide));
// Key-visual behind the name tag stays a BACKGROUND media action.
$this->assertTrue($slide->hasBackgroundMedia());
$this->assertSame('KEY_VISUAL.jpg', $slide->getBackgroundMediaUrl());
// Macro action preserved.
$this->assertTrue($slide->hasMacro());
$this->assertSame('Namenseinblender', $slide->getMacroName());
// No image content element, no foreground action.
$this->assertFalse($this->hasForegroundImageMediaAction($slide));
}
public function test_exported_song_pro_roundtrip_keeps_background_action_and_text(): void
{
Storage::fake('public');
Storage::disk('public')->put('slides/background.jpg', 'background-image');
$service = Service::factory()->create(['background_filename' => 'slides/background.jpg']);
$song = $this->createSongWithContent();
$path = app(ProExportService::class)->generateProFile($song, $service);
$parserSong = ProFileReader::read($path);
@unlink($path);
$slides = [];
foreach ($parserSong->getGroups() as $group) {
foreach ($parserSong->getSlidesForGroup($group) as $slide) {
$slides[] = $slide;
}
}
$this->assertNotEmpty($slides);
foreach ($slides as $slide) {
// Background survives the .pro write+read as a BACKGROUND media action.
$this->assertTrue($slide->hasBackgroundMedia());
$this->assertSame('BACKGROUND.jpg', $slide->getBackgroundMediaUrl());
// Text preserved; no content image element; no foreground action.
$this->assertTrue($this->elementHasTextAt($slide, 0));
$this->assertNotSame('', $slide->getPlainText());
$this->assertSame([], $this->fillMediaUrls($slide));
$this->assertFalse($this->hasForegroundImageMediaAction($slide));
}
}
private function createSongWithContent(): Song
{
$song = Song::create([
'title' => 'Fill Media Song',
'ccli_id' => '99887',
'author' => 'Test Author',
'copyright_text' => 'Test Publisher',
]);
$verse = Label::firstOrCreate(['name' => 'Verse 1 - Fill Media Song'], ['color' => '#2196F3']);
$section = $song->sections()->create(['label_id' => $verse->id, 'order' => 0]);
$section->slides()->create(['order' => 0, 'text_content' => 'Erste Zeile']);
$section->slides()->create(['order' => 1, 'text_content' => 'Zweite Zeile']);
$arrangement = $song->arrangements()->create(['name' => 'normal', 'is_default' => true]);
$arrangement->arrangementSections()->create(['song_section_id' => $section->id, 'order' => 0]);
return $song;
}
}

View file

@ -1,174 +0,0 @@
<?php
namespace Tests\Support;
use ProPresenter\Parser\Slide;
use Rv\Data\Action\ActionType;
use Rv\Data\Action\LayerType;
/**
* Test helpers for the COMBINED image rule:
*
* - BACKGROUND service image / key-visual media ACTION on the BACKGROUND
* layer. Assert via the parser API: $slide->hasBackgroundMedia() and
* $slide->getBackgroundMediaUrl().
* - UPLOADED content slide image full-frame fill.media CONTENT element.
* Assert via fillMediaUrlAt()/fillMediaUrls()/isFullFrameAt() and the
* foreground-action absence via hasForegroundImageMediaAction() == false.
* - Text (songtext / name-tag) text CONTENT element (elementHasTextAt()).
*
* Content elements are full-frame Graphics\Element fills whose z-order is the
* base-slide element array order (index 0 = bottom layer): text first, the
* uploaded foreground image appended on top. The background image is NOT an
* element; it renders beneath the stack from its BACKGROUND media action.
*/
trait InspectsSlideFillMedia
{
/**
* Ordered base-slide content elements (z-order; index 0 = bottom layer).
*
* @return array<int, \Rv\Data\Slide\Element>
*/
protected function baseSlideElements(Slide $slide): array
{
foreach ($slide->getCue()->getActions() as $action) {
if ($action->getType() !== ActionType::ACTION_TYPE_PRESENTATION_SLIDE) {
continue;
}
$baseSlide = $action->getSlide()?->getPresentation()?->getBaseSlide();
if ($baseSlide === null) {
return [];
}
$elements = [];
foreach ($baseSlide->getElements() as $element) {
$elements[] = $element;
}
return $elements;
}
return [];
}
/** Bundle-relative fill-media URL of the element at $index, or null when it has no media fill. */
protected function fillMediaUrlAt(Slide $slide, int $index): ?string
{
$elements = $this->baseSlideElements($slide);
$graphics = ($elements[$index] ?? null)?->getElement();
$fill = $graphics?->getFill();
if ($fill === null || ! $fill->hasMedia()) {
return null;
}
return $fill->getMedia()?->getUrl()?->getAbsoluteString();
}
/**
* Bounds [x, y, width, height] of the element at $index, or null.
*
* @return array{0: float, 1: float, 2: float, 3: float}|null
*/
protected function fillBoundsAt(Slide $slide, int $index): ?array
{
$elements = $this->baseSlideElements($slide);
$bounds = ($elements[$index] ?? null)?->getElement()?->getBounds();
if ($bounds === null) {
return null;
}
return [
(float) ($bounds->getOrigin()?->getX() ?? 0.0),
(float) ($bounds->getOrigin()?->getY() ?? 0.0),
(float) ($bounds->getSize()?->getWidth() ?? 0.0),
(float) ($bounds->getSize()?->getHeight() ?? 0.0),
];
}
/** True when the element at $index is a full-frame (0,0 → 1920×1080) fill. */
protected function isFullFrameAt(Slide $slide, int $index): bool
{
return $this->fillBoundsAt($slide, $index) === [0.0, 0.0, 1920.0, 1080.0];
}
/**
* All fill-media URLs on the slide, in z-order (bottom top).
*
* @return array<int, string>
*/
protected function fillMediaUrls(Slide $slide): array
{
$urls = [];
foreach ($this->baseSlideElements($slide) as $element) {
$fill = $element->getElement()?->getFill();
if ($fill !== null && $fill->hasMedia()) {
$urls[] = $fill->getMedia()?->getUrl()?->getAbsoluteString() ?? '';
}
}
return $urls;
}
/** Whether any base-slide element carries a fill-media image with the given URL. */
protected function hasFillMediaUrl(Slide $slide, string $url): bool
{
return in_array($url, $this->fillMediaUrls($slide), true);
}
/** True while the element at $index carries text (used to assert text sits above the background). */
protected function elementHasTextAt(Slide $slide, int $index): bool
{
$graphics = ($this->baseSlideElements($slide)[$index] ?? null)?->getElement();
return $graphics !== null && $graphics->hasText();
}
/**
* Whether the cue carries ANY image MEDIA action (either layer). Useful only
* when no service background is configured; when a background IS present it
* survives as a BACKGROUND action, so prefer hasForegroundImageMediaAction().
*/
protected function hasImageMediaAction(Slide $slide): bool
{
foreach ($slide->getCue()->getActions() as $action) {
if ($action->getType() !== ActionType::ACTION_TYPE_MEDIA) {
continue;
}
$mediaElement = $action->getMedia()?->getElement();
if ($mediaElement !== null && $mediaElement->hasImage()) {
return true;
}
}
return false;
}
/**
* Whether the cue still carries a FOREGROUND-layer image MEDIA action. Must be
* false after conversion uploaded images become content elements. A
* BACKGROUND-layer action (service background / key-visual) is ignored here.
*/
protected function hasForegroundImageMediaAction(Slide $slide): bool
{
foreach ($slide->getCue()->getActions() as $action) {
if ($action->getType() !== ActionType::ACTION_TYPE_MEDIA) {
continue;
}
$mediaType = $action->getMedia();
$mediaElement = $mediaType?->getElement();
if (
$mediaElement !== null
&& $mediaElement->hasImage()
&& $mediaType->getLayerType() !== LayerType::LAYER_TYPE_BACKGROUND
) {
return true;
}
}
return false;
}
}

View file

@ -16,13 +16,13 @@ git fetch --all --prune
# und database/ gitignored sind und nicht angefasst werden.
git reset --hard origin/master
# Die bussnet/propresenter7-php-lib-Version wird ausschliesslich ueber die eingecheckte
# Die propresenter/parser-Version wird ausschliesslich ueber die eingecheckte
# composer.lock gepinnt (Standard-Composer-Praxis). Der Dockerfile-Schritt
# "composer install" deployt exakt diesen Lock-Stand; "COPY composer.json
# composer.lock" bricht die Composer-Layer automatisch, sobald sich die Lock
# aendert. WICHTIG: composer.lock committen, wenn der Parser fortschreitet.
# (Kein "composer update" hier: master des Parsers wurde umbenannt zu
# bussnet/propresenter7-php-lib, ein Re-Resolve von dev-master schlaegt fehl.)
# bussnet/propresenter7-php-api, ein Re-Resolve von dev-master schlaegt fehl.)
echo "[deploy] Baue Image (inkl. aktualisierter Basis-Images) ..."
$DC build --pull

View file

@ -1,6 +1,6 @@
#!/usr/bin/env bash
#
# Toggle bussnet/propresenter7-php-lib between remote VCS and local path repository.
# Toggle propresenter/parser between remote VCS and local path repository.
#
# Usage:
# ./use_local_pp_lib.sh <path> — Switch to local checkout (symlinked)
@ -39,13 +39,13 @@ switch_to_remote() {
file_put_contents("composer.json", json_encode($json, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n");
'
if [[ -L "vendor/bussnet/propresenter7-php-lib" ]] || [[ -d "vendor/bussnet/propresenter7-php-lib" ]]; then
rm -rf vendor/bussnet/propresenter7-php-lib
if [[ -L "vendor/propresenter/parser" ]] || [[ -d "vendor/propresenter/parser" ]]; then
rm -rf vendor/propresenter/parser
fi
composer update bussnet/propresenter7-php-lib
composer update propresenter/parser
echo ""
echo "bussnet/propresenter7-php-lib wird jetzt vom Remote-Repository geladen."
echo "propresenter/parser wird jetzt vom Remote-Repository geladen."
}
switch_to_local() {
@ -63,8 +63,8 @@ switch_to_local() {
local PKG_NAME
PKG_NAME=$(php -r "echo json_decode(file_get_contents('$LOCAL_PATH/composer.json'), true)['name'] ?? '';")
if [[ "$PKG_NAME" != "bussnet/propresenter7-php-lib" ]]; then
echo "Error: Package in $LOCAL_PATH ist '$PKG_NAME', erwartet 'bussnet/propresenter7-php-lib'." >&2
if [[ "$PKG_NAME" != "propresenter/parser" ]]; then
echo "Error: Package in $LOCAL_PATH ist '$PKG_NAME', erwartet 'propresenter/parser'." >&2
exit 1
fi
@ -88,13 +88,13 @@ switch_to_local() {
file_put_contents("composer.json", json_encode($json, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n");
' -- "$REL_PATH"
if [[ -L "vendor/bussnet/propresenter7-php-lib" ]] || [[ -d "vendor/bussnet/propresenter7-php-lib" ]]; then
rm -rf vendor/bussnet/propresenter7-php-lib
if [[ -L "vendor/propresenter/parser" ]] || [[ -d "vendor/propresenter/parser" ]]; then
rm -rf vendor/propresenter/parser
fi
composer update bussnet/propresenter7-php-lib
composer update propresenter/parser
echo ""
echo "bussnet/propresenter7-php-lib ist jetzt mit $REL_PATH verlinkt (Symlink)."
echo "propresenter/parser ist jetzt mit $REL_PATH verlinkt (Symlink)."
echo "Aenderungen in $REL_PATH sind sofort verfuegbar."
}