generateParserSong($song, $service, $selected); ProFileWriter::write($parserSong, $tempPath); return $tempPath; } public function generateParserSong(Song $song, ?Service $service = null, ?SongArrangement $selected = null): \ProPresenter\Parser\Song { $song->loadMissing(['arrangements.arrangementSections.section.slides', 'arrangements.arrangementSections.section.label']); $parserSong = ProFileGenerator::generate( $song->title, $this->buildGroups($song, $service, $selected), $this->buildArrangements($song), $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); if ($chosen !== null) { $arrangement = $parserSong->getArrangementByName($chosen->name); if ($arrangement !== null) { $parserSong->setSelectedArrangementUuid($arrangement->getUuid()); } } return $parserSong; } private function buildGroups(Song $song, ?Service $service = null, ?SongArrangement $selected = null): array { $selectedArr = $this->chosenArrangement($song, $selected); if ($selectedArr === null) { return []; } $background = $this->backgroundData($service); // Ordered, de-duplicated sections of the SELECTED arrangement (its play order). $selectedSections = $this->uniqueSectionsFor($selectedArr); $selectedSectionIds = array_map(static fn ($section) => $section->id, $selectedSections); // Total slide count across the selected arrangement's play order so that 'first_slide' // and 'last_slide' macros anchor to the selected arrangement's very first/last slide. $totalSlidesInSong = 0; foreach ($selectedSections as $section) { $totalSlidesInSong += $section->slides->count(); } $groups = []; $globalSlideIndex = 0; // 1) Emit the selected arrangement's sections first, with position-aware macros. foreach ($selectedSections as $section) { $label = $section->label; $slides = []; foreach ($section->slides->sortBy('order')->values() as $slide) { $slideData = $this->baseSlideData($slide, $background); if ($service !== null) { $macros = $this->macroResolutionService->macrosForSlide( $service, 'song', ['index' => $globalSlideIndex, 'total' => $totalSlidesInSong, 'label_id' => $label->id], ); if (! empty($macros)) { // ProPresenter parser currently supports one `macro` entry per slide; keep the first resolved macro until stacked macros are supported. $slideData['macro'] = $macros[0]; } } $slides[] = $slideData; $globalSlideIndex++; } $groups[] = [ 'name' => $label->name, 'color' => ProImportService::hexToRgba($label->color ?? '#808080'), 'slides' => $slides, ]; } // 2) Append sections referenced by OTHER arrangements so every arrangement's group-name refs // resolve in the parser (unknown group names are silently dropped otherwise). These sections // are not part of the selected play order → no position-based macros, no slide-index advance. foreach ($this->appendedSections($song, $selectedSectionIds) as $section) { $slides = []; foreach ($section->slides->sortBy('order')->values() as $slide) { $slides[] = $this->baseSlideData($slide, $background); } $groups[] = [ 'name' => $section->label->name, 'color' => ProImportService::hexToRgba($section->label->color ?? '#808080'), 'slides' => $slides, ]; } return $groups; } /** * Resolve the chosen arrangement: explicit selection → default (is_default) → first. */ private function chosenArrangement(Song $song, ?SongArrangement $selected): ?SongArrangement { return $selected ?? $song->arrangements->firstWhere('is_default', true) ?? $song->arrangements->first(); } /** * Ordered, de-duplicated valid sections (section + label present) of a single arrangement. * * @return array */ private function uniqueSectionsFor(SongArrangement $arrangement): array { $arrangement->loadMissing('arrangementSections.section.slides', 'arrangementSections.section.label'); $sections = []; $seen = []; foreach ($arrangement->arrangementSections->sortBy('order') as $arrangementSection) { $section = $arrangementSection->section; if ($section === null || $section->label === null) { continue; } if (in_array($section->id, $seen, true)) { continue; } $seen[] = $section->id; $sections[] = $section; } return $sections; } /** * Sections used by any arrangement but NOT already emitted for the selected arrangement. * Stable order: arrangements in song order, sections in each arrangement's play order. * * @param array $excludeSectionIds * @return array */ private function appendedSections(Song $song, array $excludeSectionIds): array { $sections = []; $seen = $excludeSectionIds; foreach ($song->arrangements as $arrangement) { foreach ($this->uniqueSectionsFor($arrangement) as $section) { if (in_array($section->id, $seen, true)) { continue; } $seen[] = $section->id; $sections[] = $section; } } return $sections; } /** * Base slideData shared by selected and appended sections: text, optional translation, optional background. */ private function baseSlideData(object $slide, ?array $background): array { $slideData = ['text' => $slide->text_content ?? '']; if ($slide->text_content_translated) { $slideData['translation'] = $slide->text_content_translated; } if ($background !== null && ! $this->isFullCoverImageSlide($slide, $slideData)) { $slideData['background'] = $background; } return $slideData; } private function backgroundData(?Service $service): ?array { if ($service === null) { return null; } $background = $this->imageResolver->backgroundFor($service); if ($background === null) { return null; } return [ 'path' => ServiceImageResolver::BACKGROUND_EXPORT_NAME, 'format' => 'JPG', 'width' => 1920, 'height' => 1080, 'bundleRelative' => true, ]; } private function isFullCoverImageSlide(object $slide, array $slideData): bool { if (! isset($slideData['media'])) { return false; } return ($slide->cover_mode ?? null) === true; } private function buildArrangements(Song $song): array { $arrangements = []; foreach ($song->arrangements as $arrangement) { $arrangement->loadMissing('arrangementSections.section.label'); $groupNames = $arrangement->arrangementSections ->sortBy('order') ->map(fn ($arrangementSection) => $arrangementSection->section?->label?->name) ->filter() ->values() ->toArray(); $arrangements[] = [ 'name' => $arrangement->name, 'groupNames' => $groupNames, ]; } return $arrangements; } private function buildCcliMetadata(Song $song): array { return array_filter([ 'author' => $song->author, 'song_title' => $song->title, 'copyright_year' => $song->copyright_year, 'publisher' => $song->publisher, 'song_number' => $song->ccli_id ? (int) $song->ccli_id : null, ]); } }