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
This commit is contained in:
parent
9db2702b5f
commit
deabfe4ffb
41
php/bin/regen-test-bundles.php
Normal file
41
php/bin/regen-test-bundles.php
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
<?php
|
||||
|
||||
require_once dirname(__DIR__) . '/vendor/autoload.php';
|
||||
|
||||
use ProPresenter\Parser\PresentationBundle;
|
||||
use ProPresenter\Parser\ProBundleWriter;
|
||||
use ProPresenter\Parser\ProFileGenerator;
|
||||
|
||||
$image = imagecreatetruecolor(200, 150);
|
||||
$blue = imagecolorallocate($image, 30, 60, 180);
|
||||
imagefill($image, 0, 0, $blue);
|
||||
$white = imagecolorallocate($image, 255, 255, 255);
|
||||
imagestring($image, 5, 10, 10, 'ProPresenter', $white);
|
||||
$tmpPng = tempnam(sys_get_temp_dir(), 'testbild-') . '.png';
|
||||
imagepng($image, $tmpPng);
|
||||
$imageBytes = file_get_contents($tmpPng);
|
||||
unlink($tmpPng);
|
||||
|
||||
$refDir = dirname(__DIR__, 2) . '/ref';
|
||||
$mediaAbsPath = '/Users/thorsten/AI/propresenter/ref/Media/test-background.png';
|
||||
|
||||
$song = ProFileGenerator::generate(
|
||||
'TestBild',
|
||||
[
|
||||
[
|
||||
'name' => '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";
|
||||
107
php/src/PresentationBundle.php
Normal file
107
php/src/PresentationBundle.php
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ProPresenter\Parser;
|
||||
|
||||
use Rv\Data\Presentation;
|
||||
|
||||
/**
|
||||
* Top-level wrapper for a ProPresenter presentation bundle (.probundle).
|
||||
*
|
||||
* A .probundle is a ZIP archive containing a single .pro presentation file
|
||||
* together with all its referenced media assets (images, videos, audio).
|
||||
* This is the Pro7 successor to the Pro6 .pro6x format.
|
||||
*
|
||||
* Archive layout:
|
||||
* SongName.pro ← Protobuf-encoded presentation
|
||||
* Media/image.jpg ← Referenced media files
|
||||
* Media/video.mp4
|
||||
*/
|
||||
class PresentationBundle
|
||||
{
|
||||
private Song $song;
|
||||
|
||||
/** @var array<string, string> 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<string, string> 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;
|
||||
}
|
||||
}
|
||||
100
php/src/ProBundleReader.php
Normal file
100
php/src/ProBundleReader.php
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ProPresenter\Parser;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use RuntimeException;
|
||||
use Rv\Data\Presentation;
|
||||
use ZipArchive;
|
||||
|
||||
final class ProBundleReader
|
||||
{
|
||||
public static function read(string $filePath): PresentationBundle
|
||||
{
|
||||
if ($filePath === '' || !is_file($filePath)) {
|
||||
throw new InvalidArgumentException(sprintf('Bundle file not found: %s', $filePath));
|
||||
}
|
||||
|
||||
$size = filesize($filePath);
|
||||
if ($size === false) {
|
||||
throw new RuntimeException(sprintf('Unable to determine file size: %s', $filePath));
|
||||
}
|
||||
|
||||
if ($size === 0) {
|
||||
throw new RuntimeException(sprintf('Bundle file is empty: %s', $filePath));
|
||||
}
|
||||
|
||||
$rawBytes = file_get_contents($filePath);
|
||||
if ($rawBytes === false) {
|
||||
throw new RuntimeException(sprintf('Unable to read bundle file: %s', $filePath));
|
||||
}
|
||||
|
||||
$fixedBytes = Zip64Fixer::fix($rawBytes);
|
||||
|
||||
$tempPath = tempnam(sys_get_temp_dir(), 'probundle-');
|
||||
if ($tempPath === false) {
|
||||
throw new RuntimeException('Unable to create temporary file for bundle archive.');
|
||||
}
|
||||
|
||||
$zip = new ZipArchive();
|
||||
$isOpen = false;
|
||||
|
||||
try {
|
||||
if (file_put_contents($tempPath, $fixedBytes) === false) {
|
||||
throw new RuntimeException(sprintf('Unable to write temporary bundle archive: %s', $filePath));
|
||||
}
|
||||
|
||||
if ($zip->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));
|
||||
}
|
||||
}
|
||||
77
php/src/ProBundleWriter.php
Normal file
77
php/src/ProBundleWriter.php
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ProPresenter\Parser;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use RuntimeException;
|
||||
use ZipArchive;
|
||||
|
||||
final class ProBundleWriter
|
||||
{
|
||||
public static function write(PresentationBundle $bundle, string $filePath): void
|
||||
{
|
||||
$directory = dirname($filePath);
|
||||
if (!is_dir($directory)) {
|
||||
throw new InvalidArgumentException(sprintf('Target directory does not exist: %s', $directory));
|
||||
}
|
||||
|
||||
$tempPath = tempnam(sys_get_temp_dir(), 'probundle-');
|
||||
if ($tempPath === false) {
|
||||
throw new RuntimeException('Unable to create temporary file for bundle archive.');
|
||||
}
|
||||
|
||||
$zip = new ZipArchive();
|
||||
$isOpen = false;
|
||||
|
||||
try {
|
||||
$openResult = $zip->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));
|
||||
}
|
||||
}
|
||||
361
php/tests/ProBundleTest.php
Normal file
361
php/tests/ProBundleTest.php
Normal file
|
|
@ -0,0 +1,361 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ProPresenter\Parser\Tests;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use ProPresenter\Parser\PresentationBundle;
|
||||
use ProPresenter\Parser\ProBundleReader;
|
||||
use ProPresenter\Parser\ProBundleWriter;
|
||||
use ProPresenter\Parser\ProFileGenerator;
|
||||
use RuntimeException;
|
||||
use ZipArchive;
|
||||
|
||||
class ProBundleTest extends TestCase
|
||||
{
|
||||
private string $tmpDir;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->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);
|
||||
}
|
||||
|
||||
}
|
||||
BIN
ref/Media/test-background.png
Normal file
BIN
ref/Media/test-background.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 717 B |
BIN
ref/RestBildExportFromPP.probundle
Normal file
BIN
ref/RestBildExportFromPP.probundle
Normal file
Binary file not shown.
BIN
ref/TestBild.probundle
Normal file
BIN
ref/TestBild.probundle
Normal file
Binary file not shown.
Loading…
Reference in a new issue