From deabfe4ffbd574cdcd0d2af1e6376667d0db896c Mon Sep 17 00:00:00 2001 From: Thorsten Bus Date: Mon, 30 Mar 2026 08:58:48 +0200 Subject: [PATCH] feat(bundle): add .probundle reader, writer, and wrapper for presentation bundles ProPresenter .probundle files are ZIP archives containing a single .pro presentation with embedded media assets. This adds read/write support verified against actual ProPresenter 7 exports. - PresentationBundle: wrapper class (Song + media files + .pro filename) - ProBundleReader: reads .probundle ZIPs, applies Zip64Fixer for PP exports - ProBundleWriter: writes standard ZIP with media-first entry order - ProFileGenerator: media URLs now include URL.local with LocalRelativePath - 9 tests covering error handling, round-trip, PP export compat, ZIP format - ref/TestBild.probundle: verified importable by ProPresenter 7 --- php/bin/regen-test-bundles.php | 41 ++++ php/src/PresentationBundle.php | 107 +++++++++ php/src/ProBundleReader.php | 100 ++++++++ php/src/ProBundleWriter.php | 77 ++++++ php/tests/ProBundleTest.php | 361 +++++++++++++++++++++++++++++ ref/Media/test-background.png | Bin 0 -> 717 bytes ref/RestBildExportFromPP.probundle | Bin 0 -> 2126 bytes ref/TestBild.probundle | Bin 0 -> 1232 bytes 8 files changed, 686 insertions(+) create mode 100644 php/bin/regen-test-bundles.php create mode 100644 php/src/PresentationBundle.php create mode 100644 php/src/ProBundleReader.php create mode 100644 php/src/ProBundleWriter.php create mode 100644 php/tests/ProBundleTest.php create mode 100644 ref/Media/test-background.png create mode 100644 ref/RestBildExportFromPP.probundle create mode 100644 ref/TestBild.probundle diff --git a/php/bin/regen-test-bundles.php b/php/bin/regen-test-bundles.php new file mode 100644 index 0000000..7894cfe --- /dev/null +++ b/php/bin/regen-test-bundles.php @@ -0,0 +1,41 @@ + 'Verse 1', + 'color' => [0.0, 0.0, 0.0, 1.0], + 'slides' => [ + [ + 'media' => 'file://' . $mediaAbsPath, + 'format' => 'png', + ], + ], + ], + ], + [['name' => 'normal', 'groupNames' => ['Verse 1']]], +); + +$bundle = new PresentationBundle($song, 'TestBild.pro', [$mediaAbsPath => $imageBytes]); +ProBundleWriter::write($bundle, $refDir . '/TestBild.probundle'); +echo "TestBild.probundle written\n"; diff --git a/php/src/PresentationBundle.php b/php/src/PresentationBundle.php new file mode 100644 index 0000000..4c6ce24 --- /dev/null +++ b/php/src/PresentationBundle.php @@ -0,0 +1,107 @@ + relative path => raw bytes */ + private array $mediaFiles; + + private string $proFilename; + + public function __construct( + Song $song, + string $proFilename, + array $mediaFiles = [], + ) { + $this->song = $song; + $this->proFilename = $proFilename; + $this->mediaFiles = $mediaFiles; + } + + /** + * The embedded presentation/song. + */ + public function getSong(): Song + { + return $this->song; + } + + /** + * Filename of the .pro file inside the archive. + */ + public function getProFilename(): string + { + return $this->proFilename; + } + + /** + * Name of the presentation (from the embedded Song). + */ + public function getName(): string + { + return $this->song->getName(); + } + + /** + * Access the underlying protobuf Presentation. + */ + public function getPresentation(): Presentation + { + return $this->song->getPresentation(); + } + + // ─── Media files ─── + + /** + * All media files in the bundle. + * + * @return array relative path => raw bytes + */ + public function getMediaFiles(): array + { + return $this->mediaFiles; + } + + /** + * Number of media files in the bundle. + */ + public function getMediaFileCount(): int + { + return count($this->mediaFiles); + } + + /** + * Check if a specific media file exists in the bundle. + */ + public function hasMediaFile(string $path): bool + { + return isset($this->mediaFiles[$path]); + } + + /** + * Get a specific media file's raw bytes. + */ + public function getMediaFile(string $path): ?string + { + return $this->mediaFiles[$path] ?? null; + } +} diff --git a/php/src/ProBundleReader.php b/php/src/ProBundleReader.php new file mode 100644 index 0000000..7d95077 --- /dev/null +++ b/php/src/ProBundleReader.php @@ -0,0 +1,100 @@ +open($tempPath) !== true) { + throw new RuntimeException(sprintf('Failed to open bundle archive: %s', $filePath)); + } + $isOpen = true; + + $proFilename = self::findProFile($zip, $filePath); + + $proBytes = $zip->getFromName($proFilename); + if ($proBytes === false) { + throw new RuntimeException(sprintf('Unable to read .pro entry %s: %s', $proFilename, $filePath)); + } + + $presentation = new Presentation(); + $presentation->mergeFromString($proBytes); + $song = new Song($presentation); + + $mediaFiles = []; + for ($i = 0; $i < $zip->numFiles; $i++) { + $name = $zip->getNameIndex($i); + if ($name === false || $name === $proFilename) { + continue; + } + + $contents = $zip->getFromIndex($i); + if ($contents === false) { + throw new RuntimeException(sprintf('Unable to read ZIP entry %s: %s', $name, $filePath)); + } + + $mediaFiles[$name] = $contents; + } + + return new PresentationBundle($song, $proFilename, $mediaFiles); + } finally { + if ($isOpen) { + $zip->close(); + } + @unlink($tempPath); + } + } + + private static function findProFile(ZipArchive $zip, string $filePath): string + { + for ($i = 0; $i < $zip->numFiles; $i++) { + $name = $zip->getNameIndex($i); + if ($name !== false && str_ends_with(strtolower($name), '.pro')) { + return $name; + } + } + + throw new RuntimeException(sprintf('No .pro file found in bundle archive: %s', $filePath)); + } +} diff --git a/php/src/ProBundleWriter.php b/php/src/ProBundleWriter.php new file mode 100644 index 0000000..d9ce66f --- /dev/null +++ b/php/src/ProBundleWriter.php @@ -0,0 +1,77 @@ +open($tempPath, ZipArchive::CREATE | ZipArchive::OVERWRITE); + if ($openResult !== true) { + throw new RuntimeException(sprintf('Failed to create bundle archive: %s', $filePath)); + } + $isOpen = true; + + foreach ($bundle->getMediaFiles() as $entryName => $contents) { + self::addEntry($zip, $entryName, $contents, $filePath); + } + + $proBytes = $bundle->getPresentation()->serializeToString(); + self::addEntry($zip, $bundle->getProFilename(), $proBytes, $filePath); + + if (!$zip->close()) { + throw new RuntimeException(sprintf('Failed to finalize bundle archive: %s', $filePath)); + } + $isOpen = false; + + self::moveTempFileToTarget($tempPath, $filePath); + } finally { + if ($isOpen) { + $zip->close(); + } + if (is_file($tempPath)) { + @unlink($tempPath); + } + } + } + + private static function addEntry(ZipArchive $zip, string $entryName, string $contents, string $filePath): void + { + if (!$zip->addFromString($entryName, $contents)) { + throw new RuntimeException(sprintf('Failed to add ZIP entry %s: %s', $entryName, $filePath)); + } + } + + private static function moveTempFileToTarget(string $tempPath, string $filePath): void + { + if (@rename($tempPath, $filePath)) { + return; + } + + if (@copy($tempPath, $filePath) && @unlink($tempPath)) { + return; + } + + throw new RuntimeException(sprintf('Unable to write bundle file: %s', $filePath)); + } +} diff --git a/php/tests/ProBundleTest.php b/php/tests/ProBundleTest.php new file mode 100644 index 0000000..4dc6752 --- /dev/null +++ b/php/tests/ProBundleTest.php @@ -0,0 +1,361 @@ +tmpDir = sys_get_temp_dir() . '/propresenter-bundle-test-' . uniqid(); + mkdir($this->tmpDir, 0777, true); + } + + protected function tearDown(): void + { + if (!is_dir($this->tmpDir)) { + return; + } + + $this->removeDirectoryRecursively($this->tmpDir); + } + + #[Test] + public function readerThrowsWhenFileNotFound(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Bundle file not found'); + + ProBundleReader::read('/nonexistent/path.probundle'); + } + + #[Test] + public function readerThrowsWhenFileIsEmpty(): void + { + $emptyFile = $this->tmpDir . '/empty.probundle'; + file_put_contents($emptyFile, ''); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Bundle file is empty'); + + ProBundleReader::read($emptyFile); + } + + #[Test] + public function writerThrowsWhenTargetDirectoryMissing(): void + { + $bundle = new PresentationBundle( + ProFileGenerator::generate('Dummy', [ + ['name' => 'V1', 'color' => [0, 0, 0, 1], 'slides' => [['text' => 'x']]], + ], [['name' => 'n', 'groupNames' => ['V1']]]), + 'Dummy.pro', + ); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Target directory does not exist'); + + ProBundleWriter::write($bundle, '/nonexistent/dir/out.probundle'); + } + + #[Test] + public function writeAndReadBundleWithRealImage(): void + { + $imagePath = $this->tmpDir . '/test-background.png'; + $this->createTestPngImage($imagePath, 200, 150); + $imageBytes = file_get_contents($imagePath); + $this->assertNotFalse($imageBytes); + + $song = ProFileGenerator::generate( + 'Bundle Test Song', + [ + [ + 'name' => 'Verse 1', + 'color' => [0.2, 0.4, 0.8, 1.0], + 'slides' => [ + ['text' => 'Amazing Grace, how sweet the sound'], + ['text' => 'That saved a wretch like me'], + ], + ], + [ + 'name' => 'Chorus', + 'color' => [0.8, 0.2, 0.2, 1.0], + 'slides' => [ + ['text' => 'I once was lost, but now am found'], + ], + ], + ], + [ + ['name' => 'normal', 'groupNames' => ['Verse 1', 'Chorus']], + ], + ); + + $bundle = new PresentationBundle( + $song, + 'Bundle Test Song.pro', + ['Media/test-background.png' => $imageBytes], + ); + + $bundlePath = $this->tmpDir . '/BundleTestSong.probundle'; + ProBundleWriter::write($bundle, $bundlePath); + + $this->assertFileExists($bundlePath); + $this->assertGreaterThan(0, filesize($bundlePath)); + + $zip = new ZipArchive(); + $this->assertTrue($zip->open($bundlePath) === true); + $this->assertNotFalse($zip->locateName('Bundle Test Song.pro')); + $this->assertNotFalse($zip->locateName('Media/test-background.png')); + $this->assertSame(2, $zip->numFiles); + $zip->close(); + + $readBundle = ProBundleReader::read($bundlePath); + + $this->assertSame('Bundle Test Song', $readBundle->getName()); + $this->assertSame('Bundle Test Song.pro', $readBundle->getProFilename()); + $this->assertSame(1, $readBundle->getMediaFileCount()); + $this->assertTrue($readBundle->hasMediaFile('Media/test-background.png')); + $this->assertSame($imageBytes, $readBundle->getMediaFile('Media/test-background.png')); + + $readSong = $readBundle->getSong(); + $this->assertSame('Bundle Test Song', $readSong->getName()); + $this->assertCount(2, $readSong->getGroups()); + $this->assertCount(3, $readSong->getSlides()); + } + + #[Test] + public function writeAndReadBundleWithMultipleMediaFiles(): void + { + $image1Path = $this->tmpDir . '/slide1.png'; + $image2Path = $this->tmpDir . '/slide2.png'; + $this->createTestPngImage($image1Path, 100, 100); + $this->createTestPngImage($image2Path, 320, 240); + + $image1Bytes = file_get_contents($image1Path); + $image2Bytes = file_get_contents($image2Path); + $this->assertNotFalse($image1Bytes); + $this->assertNotFalse($image2Bytes); + + $song = ProFileGenerator::generate( + 'Multi Media Song', + [ + [ + 'name' => 'Verse 1', + 'color' => [0.1, 0.2, 0.3, 1.0], + 'slides' => [ + ['text' => 'Slide with media'], + ], + ], + ], + [ + ['name' => 'normal', 'groupNames' => ['Verse 1']], + ], + ); + + $bundle = new PresentationBundle( + $song, + 'Multi Media Song.pro', + [ + 'Media/slide1.png' => $image1Bytes, + 'Media/slide2.png' => $image2Bytes, + ], + ); + + $bundlePath = $this->tmpDir . '/MultiMedia.probundle'; + ProBundleWriter::write($bundle, $bundlePath); + + $readBundle = ProBundleReader::read($bundlePath); + + $this->assertSame(2, $readBundle->getMediaFileCount()); + $this->assertTrue($readBundle->hasMediaFile('Media/slide1.png')); + $this->assertTrue($readBundle->hasMediaFile('Media/slide2.png')); + $this->assertSame($image1Bytes, $readBundle->getMediaFile('Media/slide1.png')); + $this->assertSame($image2Bytes, $readBundle->getMediaFile('Media/slide2.png')); + } + + #[Test] + public function writeAndReadBundleWithoutMediaFiles(): void + { + $song = ProFileGenerator::generate( + 'No Media Song', + [ + [ + 'name' => 'Verse 1', + 'color' => [0.1, 0.2, 0.3, 1.0], + 'slides' => [ + ['text' => 'Just lyrics, no media'], + ], + ], + ], + [ + ['name' => 'normal', 'groupNames' => ['Verse 1']], + ], + ); + + $bundle = new PresentationBundle($song, 'No Media Song.pro'); + + $bundlePath = $this->tmpDir . '/NoMedia.probundle'; + ProBundleWriter::write($bundle, $bundlePath); + + $readBundle = ProBundleReader::read($bundlePath); + + $this->assertSame('No Media Song', $readBundle->getName()); + $this->assertSame(0, $readBundle->getMediaFileCount()); + $this->assertFalse($readBundle->hasMediaFile('anything')); + $this->assertNull($readBundle->getMediaFile('anything')); + } + + #[Test] + public function readerHandlesProPresenterExportedBundle(): void + { + $ppExportPath = dirname(__DIR__, 2) . '/ref/RestBildExportFromPP.probundle'; + if (!is_file($ppExportPath)) { + $this->markTestSkipped('PP-exported reference file not available'); + } + + $bundle = ProBundleReader::read($ppExportPath); + + $this->assertSame('TestBild', $bundle->getName()); + $this->assertSame('TestBild.pro', $bundle->getProFilename()); + $this->assertSame(1, $bundle->getMediaFileCount()); + + $slide = $bundle->getSong()->getSlides()[0]; + $this->assertTrue($slide->hasMedia()); + $this->assertStringStartsWith('file:///', $slide->getMediaUrl()); + $this->assertSame('png', $slide->getMediaFormat()); + } + + #[Test] + public function writeProducesStandardZipWithAbsoluteMediaPaths(): void + { + $imagePath = $this->tmpDir . '/bg.png'; + $this->createTestPngImage($imagePath, 100, 100); + $imageBytes = file_get_contents($imagePath); + $this->assertNotFalse($imageBytes); + + $mediaAbsPath = '/Users/test/Media/bg.png'; + + $song = ProFileGenerator::generate( + 'ZipFormatTest', + [ + [ + 'name' => 'V1', + 'color' => [0, 0, 0, 1], + 'slides' => [ + [ + 'media' => 'file://' . $mediaAbsPath, + 'format' => 'png', + ], + ], + ], + ], + [['name' => 'normal', 'groupNames' => ['V1']]], + ); + + $bundle = new PresentationBundle( + $song, + 'ZipFormatTest.pro', + [$mediaAbsPath => $imageBytes], + ); + + $bundlePath = $this->tmpDir . '/ZipFormatTest.probundle'; + ProBundleWriter::write($bundle, $bundlePath); + + $zip = new ZipArchive(); + $this->assertTrue($zip->open($bundlePath) === true); + $this->assertSame(2, $zip->numFiles); + + $mediaIdx = $zip->locateName($mediaAbsPath); + $this->assertNotFalse($mediaIdx, 'Media entry should use absolute path with leading /'); + + $proIdx = $zip->locateName('ZipFormatTest.pro'); + $this->assertNotFalse($proIdx); + $this->assertGreaterThan($mediaIdx, $proIdx, 'Media entries should come before .pro entry'); + + $zip->close(); + + $readBundle = ProBundleReader::read($bundlePath); + $this->assertSame('ZipFormatTest', $readBundle->getName()); + $this->assertTrue($readBundle->hasMediaFile($mediaAbsPath)); + $this->assertSame($imageBytes, $readBundle->getMediaFile($mediaAbsPath)); + } + + #[Test] + public function bundleWrapperExposesAllProperties(): void + { + $song = ProFileGenerator::generate( + 'Wrapper Test', + [ + [ + 'name' => 'V1', + 'color' => [0, 0, 0, 1], + 'slides' => [['text' => 'Hello']], + ], + ], + [['name' => 'normal', 'groupNames' => ['V1']]], + ); + + $bundle = new PresentationBundle( + $song, + 'Wrapper Test.pro', + ['Media/bg.jpg' => 'fake-jpeg-bytes'], + ); + + $this->assertSame('Wrapper Test', $bundle->getName()); + $this->assertSame('Wrapper Test.pro', $bundle->getProFilename()); + $this->assertSame($song, $bundle->getSong()); + $this->assertSame($song->getPresentation(), $bundle->getPresentation()); + $this->assertSame(1, $bundle->getMediaFileCount()); + $this->assertTrue($bundle->hasMediaFile('Media/bg.jpg')); + $this->assertSame('fake-jpeg-bytes', $bundle->getMediaFile('Media/bg.jpg')); + $this->assertSame(['Media/bg.jpg' => 'fake-jpeg-bytes'], $bundle->getMediaFiles()); + } + + private function createTestPngImage(string $path, int $width, int $height): void + { + $image = imagecreatetruecolor($width, $height); + $this->assertNotFalse($image); + + $blue = imagecolorallocate($image, 30, 60, 180); + $this->assertNotFalse($blue); + imagefill($image, 0, 0, $blue); + + $white = imagecolorallocate($image, 255, 255, 255); + $this->assertNotFalse($white); + imagestring($image, 5, 10, 10, 'ProPresenter', $white); + + imagepng($image, $path); + } + + private function removeDirectoryRecursively(string $path): void + { + foreach (scandir($path) ?: [] as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + + $entryPath = $path . '/' . $entry; + if (is_dir($entryPath)) { + $this->removeDirectoryRecursively($entryPath); + continue; + } + + @unlink($entryPath); + } + + @rmdir($path); + } + +} diff --git a/ref/Media/test-background.png b/ref/Media/test-background.png new file mode 100644 index 0000000000000000000000000000000000000000..a1941a82231be4f745374065e29d0d8620e2e80c GIT binary patch literal 717 zcmeAS@N?(olHy`uVBq!ia0vp^CxCbw2NRGK*%#mkq&N#aB8wRq_>O=u<5X=vX$A(S zdQTU}kcv5PZ(Yp2<-pMJaM>xDw@PnW*39-hpptWmU8SWa!TMPJtSRrSyL{elcr)Kx z`03N*>$m4`*#7?iFP0znoZT%OJUFltM_&Iqz5H>a&5zID=bwGQ{dY@^A9v#1*!w>& ze=M+)lk-XZ&RJgmkD+GU=3h=Zttw$_Bg|v%E??h$J4JI>WzBca<8Mm~ul$IcJY8+S z*}UhES{@%*{>kWnf8FPPm&v<}YNemm->W^eamm!>8`CCzY`P<})#7<@r0BK1t2aM; z<=E=nZv#-wTxVcZXZcp{Uaoqmt<7wsditDuZm}hLYcE8h|DRaF!}=C9a6fOv4hFeiKghA;qLbLBj~oV1V_ zU!Ikz=3@9M{tp>-`jvgI91KkU%&kEHVh8k}Xikky0Kib6rOI1&zcqH{%bi#3VDAs_ zf0j6wJQjKENKU`i`&Ep!p{8*CC-sNl9N)8l)9tQXZR_JkM+Z;z__}(=XYWN$XP91Z z=*l!Mx((d?{qyz_clz|?RNIB|p5Gd3a+bN@-hOTR;)FjfDQS~?%2ZuF18P3#zNdI! zw&oqr7q@S?c=^n?#cNN$STkiB9IM)OY5Liwmen5#tv7yb80l~Sm6>g#Tf*f%Qq)&}kW?cc{>#xPnE zcb3XKUVz^x$kKAuPNi_4YoUlH;X5mmI3fLV>0P$f-zx3QJK?bs$Yfyedl+OA(pNP7|tGA=4SNDU{Pz`--{4_ixzK zuo%qZK-fCSh>Qd|${|2m5H%<{X^y1@n${SUtf%LWLD%Y_C5}ddjMeBKN(={O73YfP zk&~4{vvx?BniwQ0Cp4RD)PTDPysHY zkO+YNglX+u&iVs(S2kftiumh!7)o+@zrSGq-E{o39gDS;umu3<%_Cg#|I}by+0oJK p=2^pD>gfE4UVwHYL=+nnf|zgabZZ{huU*+u(fX|ce>#1L_75^%bn^fJ literal 0 HcmV?d00001 diff --git a/ref/TestBild.probundle b/ref/TestBild.probundle new file mode 100644 index 0000000000000000000000000000000000000000..bfcf631f7b5324f9160746f138eac278193064f4 GIT binary patch literal 1232 zcmWIWW@Zs#U}E54_~=#_qh4sHIGvG!;VcsagDnFCgMMgnYEiL%Nk)EAaY<^PzN4pp zK~a7|QEG8&UP)?^eo<=)PI{D- zknrH^mjX_vOX>|3QtZMLL~e>dU|(XvZKKpJDeDXd*w%7JM+zY!KC8O*riL=4*rVxyGHZ<>fJT>ax0AQNBwwt`SI)R z&+~8m`}bden*6spcXg$0EH((a$Njh5`=@Zx+5YqQpUnuXgP8-;(G5_0?{B`)lH&S7Ix-|9LR6VBPialX$LW z#`l}+^_NFIp1HPYzW3ifiT|q)9-p7T%x~(feYZ+J?p^K{`P%Zib)N4h-RQzQ|6V@M zeEPZT^_K55(rf?Cw10otS9Z2-``Le%6_&0mw(RH=S|a>^#=K>m@+KV7()>R2dFHLF z(|bKfXcj{NN{lm>Ej<1c7~{3f3=BNL7!Lu)qf=%MFanG6j|QFY5_S}M>-TKpmiNb8 zJES?aBzBZt->uEDEI-s#i^FKei8W_t%rlgld_mSx;9#q^;|K1T9iOC+v_9znp&533 z?&=+z#7fPd-?8fN(knewvA+0Q&GPH-TAPjMb~o!KI~AN`>0j>}K1Fp_2AhqsNI)i&U!=>HYqOeUd>039Q8l|) z9}-qtIrGbZ3kjCqzfE55DOv|SCo^4_WLIfvDn7N);1HME{QA3{+gg$e0?Z?x7Ug?# zOi=mW#3Q(%HA8QC$+H=!Ogs