From 4e1ac9b7eab8b5d0c2aaf50e5e2f8c89f3fdbcce Mon Sep 17 00:00:00 2001 From: Thorsten Bus Date: Sun, 3 May 2026 20:53:45 +0200 Subject: [PATCH] feat(labels): add reader for global Labels file Mirrors the Macros reader: LabelsFileReader -> LabelLibrary -> Label wraps the protobuf ProLabelsDocument and exposes each label's name (the proto 'text' field) plus optional RGBA color with a #RRGGBB hex helper. Includes CLI tool, PHPUnit suite against the bundled reference Labels sample, and api/labels.md docs. --- AGENTS.md | 7 +- bin/parse-labels.php | 47 +++++++++++ doc/INDEX.md | 10 ++- doc/api/labels.md | 137 +++++++++++++++++++++++++++++++++ doc/keywords.md | 14 ++-- doc/reference_samples/Labels | Bin 0 -> 527 bytes src/Label.php | 91 ++++++++++++++++++++++ src/LabelLibrary.php | 83 ++++++++++++++++++++ src/LabelsFileReader.php | 42 ++++++++++ tests/LabelsFileReaderTest.php | 136 ++++++++++++++++++++++++++++++++ 10 files changed, 560 insertions(+), 7 deletions(-) create mode 100755 bin/parse-labels.php create mode 100644 doc/api/labels.md create mode 100644 doc/reference_samples/Labels create mode 100644 src/Label.php create mode 100644 src/LabelLibrary.php create mode 100644 src/LabelsFileReader.php create mode 100644 tests/LabelsFileReaderTest.php diff --git a/AGENTS.md b/AGENTS.md index 2e25e06..251c55d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,6 +20,7 @@ All project documentation lives in `doc/`. Load only what you need. | Parse/modify `.proplaylist` files | `doc/api/playlist.md` | | Parse/modify `.probundle` files | `doc/api/bundle.md` | | Read the global `Macros` file | `doc/api/macros.md` | +| Read the global `Labels` file | `doc/api/labels.md` | | Understand `.pro` binary format | `doc/formats/pp_song_spec.md` | | Understand `.proplaylist` binary format | `doc/formats/pp_playlist_spec.md` | | Understand `.probundle` binary format | `doc/formats/pp_bundle_spec.md` | @@ -40,7 +41,9 @@ doc/ ├── api/ ← PHP API docs (read/write/generate) │ ├── song.md │ ├── playlist.md -│ └── bundle.md +│ ├── bundle.md +│ ├── macros.md +│ └── labels.md └── internal/ ← Dev notes (learnings, decisions, issues) ├── learnings.md ├── decisions.md @@ -55,6 +58,7 @@ PHP tools for parsing, modifying, and generating ProPresenter 7 files: - **Playlists** (`.proplaylist`) — ZIP64 archives containing playlist metadata and embedded songs - **Bundles** (`.probundle`) — ZIP archives containing a single presentation with embedded media assets - **Macros** (`Macros`, no extension) — Global protobuf-encoded macro library with collections +- **Labels** (`Labels`, no extension) — Global protobuf-encoded label library (slide labels with optional UI colors) ### CLI Tools @@ -62,6 +66,7 @@ PHP tools for parsing, modifying, and generating ProPresenter 7 files: php bin/parse-song.php path/to/song.pro php bin/parse-playlist.php path/to/playlist.proplaylist php bin/parse-macros.php path/to/Macros +php bin/parse-labels.php path/to/Labels ``` ### Key Source Files diff --git a/bin/parse-labels.php b/bin/parse-labels.php new file mode 100755 index 0000000..92e3ddc --- /dev/null +++ b/bin/parse-labels.php @@ -0,0 +1,47 @@ +#!/usr/bin/env php +\n"; + exit(1); +} + +$filePath = $argv[1]; + +try { + $library = LabelsFileReader::read($filePath); +} catch (Exception $e) { + echo "Error: " . $e->getMessage() . "\n"; + exit(1); +} + +$labels = $library->getLabels(); + +echo "Labels (" . count($labels) . "):\n"; +foreach ($labels as $index => $label) { + $number = $index + 1; + $name = $label->getName(); + $displayName = $name === '' ? '(unnamed)' : $name; + + if ($label->hasColor()) { + $color = $label->getColor(); + $colorPart = sprintf( + '%s rgba(%.3f, %.3f, %.3f, %.3f)', + $label->getColorHex(), + $color['r'], + $color['g'], + $color['b'], + $color['a'], + ); + } else { + $colorPart = '(no color)'; + } + + echo sprintf(" [%d] %s :: %s\n", $number, $displayName, $colorPart); +} diff --git a/doc/INDEX.md b/doc/INDEX.md index db38615..30a6323 100644 --- a/doc/INDEX.md +++ b/doc/INDEX.md @@ -10,6 +10,7 @@ | Parse/modify `.proplaylist` files | [api/playlist.md](api/playlist.md) | | Parse/modify `.probundle` files | [api/bundle.md](api/bundle.md) | | Read the global `Macros` file | [api/macros.md](api/macros.md) | +| Read the global `Labels` file | [api/labels.md](api/labels.md) | | Understand `.pro` binary format | [formats/pp_song_spec.md](formats/pp_song_spec.md) | | Understand `.proplaylist` format | [formats/pp_playlist_spec.md](formats/pp_playlist_spec.md) | | Understand `.probundle` format | [formats/pp_bundle_spec.md](formats/pp_bundle_spec.md) | @@ -30,6 +31,7 @@ - [api/playlist.md](api/playlist.md) — Playlist parser API (read, modify, generate `.proplaylist` files) - [api/bundle.md](api/bundle.md) — Bundle parser API (read, write `.probundle` files with media) - [api/macros.md](api/macros.md) — Macros library API (read the global `Macros` file) +- [api/labels.md](api/labels.md) — Labels library API (read the global `Labels` file) ### Internal Reference - [internal/learnings.md](internal/learnings.md) — Development learnings and conventions discovered @@ -57,7 +59,8 @@ doc/ │ ├── song.md │ ├── playlist.md │ ├── bundle.md -│ └── macros.md +│ ├── macros.md +│ └── labels.md └── internal/ ← Development notes (optional context) ├── learnings.md ├── decisions.md @@ -128,6 +131,8 @@ This project provides PHP tools to parse, modify, and generate ProPresenter 7 fi | `src/ProBundleWriter.php` | Write `.probundle` files | | `src/MacroLibrary.php` | Macros library wrapper (read global `Macros` file) | | `src/MacrosFileReader.php` | Read global `Macros` file | +| `src/LabelLibrary.php` | Labels library wrapper (read global `Labels` file) | +| `src/LabelsFileReader.php` | Read global `Labels` file | ### CLI Tools @@ -140,4 +145,7 @@ php bin/parse-playlist.php path/to/playlist.proplaylist # Parse and display the global Macros file php bin/parse-macros.php path/to/Macros + +# Parse and display the global Labels file +php bin/parse-labels.php path/to/Labels ``` diff --git a/doc/api/labels.md b/doc/api/labels.md new file mode 100644 index 0000000..5059a06 --- /dev/null +++ b/doc/api/labels.md @@ -0,0 +1,137 @@ +# Labels Library API + +> PHP module for reading the global ProPresenter `Labels` file (raw protobuf, +> no extension) and exposing each label's name and UI color. + +## Quick Reference + +```php +use ProPresenter\Parser\LabelsFileReader; + +$library = LabelsFileReader::read('/path/to/Labels'); + +foreach ($library->getLabels() as $label) { + $label->getName(); // "KeyVisual Beamer" + $label->hasColor(); // bool + $label->getColor(); // ['r'=>0.0,'g'=>0.408,'b'=>0.702,'a'=>1.0] | null + $label->getColorHex(); // "#0068B3" | null +} +``` + +--- + +## File Layout + +The `Labels` file is the protobuf-serialised +[`ProLabelsDocument`](../../proto/labels.proto): + +| Field | Type | Description | +|-------|------|-------------| +| `labels` | repeated `Action.Label` | Definitions: text + optional color | + +Each `Action.Label` carries: + +| Field | Type | Description | +|-------|------|-------------| +| `text` | string | Display name (exposed as `getName()` on the wrapper) | +| `color` | `Color` (optional) | RGBA float channels in 0..1; absent for system / "no color" labels | + +Labels are identified by name only — there is no UUID. Slides reference +labels by name from inside `.pro` files. + +--- + +## Reading + +```php +use ProPresenter\Parser\LabelsFileReader; + +$library = LabelsFileReader::read('/Users/me/.../Labels'); +``` + +Throws `InvalidArgumentException` for missing files and `RuntimeException` for +empty / unreadable files. + +--- + +## LabelLibrary + +Top-level wrapper around `Rv\Data\ProLabelsDocument`. Indexes labels by name +for fast lookup. + +```php +$library->getLabels(); // Label[] +$library->count(); // int +$library->getLabelByName('Szene 1'); // ?Label (case-sensitive) +$library->findLabelByName('szene 1'); // ?Label (case-insensitive) +$library->getDocument(); // \Rv\Data\ProLabelsDocument +``` + +If the same name appears more than once in the source document the first +occurrence wins for both lookup helpers; every entry is preserved in +`getLabels()` in document order. + +--- + +## Label + +```php +$label->getName(); // "KeyVisual Beamer" (proto field is `text`) +$label->hasColor(); // bool — was a Color message present? +$label->getColor(); // ['r'=>..,'g'=>..,'b'=>..,'a'=>..] | null +$label->getColorHex(); // "#RRGGBB" uppercase, alpha dropped, or null +$label->getProto(); // \Rv\Data\Action\Label (raw protobuf) +``` + +Color channels are floats in 0..1 as ProPresenter stores them. `getColorHex()` +clamps and rounds each channel to 8 bits before formatting. + +A label can legitimately exist without a `color` message. Treat that as +"use the default UI color", not as black. The reference sample's first four +labels (`Leere Folie`, `Instrumental`, `Wiederholen`, `Gesprochenes Wort`) +hit this case. + +--- + +## CLI Tool + +```bash +php bin/parse-labels.php /path/to/Labels +``` + +Output: + +``` +Labels (15): + [1] Leere Folie :: (no color) + [2] Instrumental :: (no color) + [3] Wiederholen :: (no color) + [4] Gesprochenes Wort :: (no color) + [5] KeyVisual Stream & Beamer mit Countdown :: #CC298B rgba(0.800, 0.161, 0.545, 1.000) + [6] KeyVisual Stream & Beamer mit Jingle :: #7600CC rgba(0.463, 0.000, 0.800, 1.000) + ... +``` + +--- + +## Key Files + +| File | Purpose | +|------|---------| +| `src/LabelLibrary.php` | Document-level wrapper with name lookups | +| `src/Label.php` | Single label wrapper (name, color, hex) | +| `src/LabelsFileReader.php` | Reads the `Labels` file | +| `bin/parse-labels.php` | CLI tool | +| `proto/labels.proto` | Protobuf schema (just imports `Action.Label`) | +| `proto/action.proto` | Defines the inner `Action.Label` message | +| `generated/Rv/Data/ProLabelsDocument.php` | Generated message class | +| `generated/Rv/Data/Action/Label.php` | Generated label message class | + +--- + +## Scope Notes + +This module is read-only by design. Writing the `Labels` file back, editing +slide-side label references on `.pro` files, or syncing labels across devices +are not implemented here. Add them by mirroring the `ProFileWriter` / +`ProFileGenerator` pattern when needed. diff --git a/doc/keywords.md b/doc/keywords.md index f89cb01..1db1e96 100644 --- a/doc/keywords.md +++ b/doc/keywords.md @@ -74,16 +74,20 @@ | image | [formats/pp_song_spec.md](formats/pp_song_spec.md) Section 5, [formats/pp_bundle_spec.md](formats/pp_bundle_spec.md) Section 3 | | video | [formats/pp_song_spec.md](formats/pp_song_spec.md) Section 5, [formats/pp_bundle_spec.md](formats/pp_bundle_spec.md) Section 6 | | cue | [formats/pp_song_spec.md](formats/pp_song_spec.md) Section 5 | -| label | [api/song.md](api/song.md), [formats/pp_song_spec.md](formats/pp_song_spec.md) Section 5 | +| label | [api/labels.md](api/labels.md), [api/song.md](api/song.md), [formats/pp_song_spec.md](formats/pp_song_spec.md) Section 5 | +| Labels file | [api/labels.md](api/labels.md) | +| LabelLibrary | [api/labels.md](api/labels.md) | +| LabelsFileReader | [api/labels.md](api/labels.md) | +| color | [api/labels.md](api/labels.md), [api/macros.md](api/macros.md) | ## PHP API | Keyword | Document | |---------|----------| -| read | [api/song.md](api/song.md), [api/playlist.md](api/playlist.md), [api/bundle.md](api/bundle.md), [api/macros.md](api/macros.md) | +| read | [api/song.md](api/song.md), [api/playlist.md](api/playlist.md), [api/bundle.md](api/bundle.md), [api/macros.md](api/macros.md), [api/labels.md](api/labels.md) | | write | [api/song.md](api/song.md), [api/playlist.md](api/playlist.md), [api/bundle.md](api/bundle.md) | | generate | [api/song.md](api/song.md), [api/playlist.md](api/playlist.md) | -| parse | [api/song.md](api/song.md), [api/playlist.md](api/playlist.md), [api/bundle.md](api/bundle.md), [api/macros.md](api/macros.md) | +| parse | [api/song.md](api/song.md), [api/playlist.md](api/playlist.md), [api/bundle.md](api/bundle.md), [api/macros.md](api/macros.md), [api/labels.md](api/labels.md) | | MacrosFileReader | [api/macros.md](api/macros.md) | | ProFileReader | [api/song.md](api/song.md) | | ProFileWriter | [api/song.md](api/song.md) | @@ -96,8 +100,8 @@ | PresentationBundle | [api/bundle.md](api/bundle.md) | | Song | [api/song.md](api/song.md) | | PlaylistArchive | [api/playlist.md](api/playlist.md) | -| CLI | [api/song.md](api/song.md), [api/playlist.md](api/playlist.md), [api/macros.md](api/macros.md) | -| command line | [api/song.md](api/song.md), [api/playlist.md](api/playlist.md), [api/macros.md](api/macros.md) | +| CLI | [api/song.md](api/song.md), [api/playlist.md](api/playlist.md), [api/macros.md](api/macros.md), [api/labels.md](api/labels.md) | +| command line | [api/song.md](api/song.md), [api/playlist.md](api/playlist.md), [api/macros.md](api/macros.md), [api/labels.md](api/labels.md) | ## Protobuf diff --git a/doc/reference_samples/Labels b/doc/reference_samples/Labels new file mode 100644 index 0000000000000000000000000000000000000000..c3803b9cfea9131fa60cc0825b65e8a296c949eb GIT binary patch literal 527 zcmd<$72@_uO)W}QaLdohOy%Me;_=KYE-5O_P0cGw%;5q`hi9gyq!wl5=cMLw2@46j zrxq6!%1?T+I zypojs@;oUK-m__wL@QL&Tl>E^aqWngHq=Q0;k!Eb<9W?p(usuVx(+c$6QWI;wi zG#Uy?;?#&@7sx`07G)s;gcgV!rT9fJUbtW0h%{) zf`FYUNR{l&88e{f=?jTtC{-{wP*7C>YM3=s(q42zKfk@~&mTW*Ar6oe67>UyOkQzn zW?m97Oo3sm;GLP1BgG1l*ANmwm&aj}ln{Gx6)+kU3^79nE@uRk+q`L$oh-;L5F6lf O#$bIppb$L+@eTk1IH$e< literal 0 HcmV?d00001 diff --git a/src/Label.php b/src/Label.php new file mode 100644 index 0000000..797e1fa --- /dev/null +++ b/src/Label.php @@ -0,0 +1,91 @@ +label->getText(); + } + + /** + * Whether the label carries an explicit color message (the absence of a + * color is not the same as black: ProPresenter renders unset labels with + * the default UI color). + */ + public function hasColor(): bool + { + return $this->label->hasColor(); + } + + /** + * Get the label's color as an associative array, or null when unset. + * + * Channels are floats in the 0..1 range as stored by ProPresenter. + * + * @return array{r: float, g: float, b: float, a: float}|null + */ + public function getColor(): ?array + { + if (!$this->label->hasColor()) { + return null; + } + + $color = $this->label->getColor(); + + return [ + 'r' => $color->getRed(), + 'g' => $color->getGreen(), + 'b' => $color->getBlue(), + 'a' => $color->getAlpha(), + ]; + } + + /** + * Convenience: render the color as a 6-digit `#RRGGBB` hex string, + * dropping alpha. Returns null when the label has no color. + */ + public function getColorHex(): ?string + { + $color = $this->getColor(); + if ($color === null) { + return null; + } + + return sprintf( + '#%02X%02X%02X', + (int) round(max(0.0, min(1.0, $color['r'])) * 255), + (int) round(max(0.0, min(1.0, $color['g'])) * 255), + (int) round(max(0.0, min(1.0, $color['b'])) * 255), + ); + } + + /** + * Get the underlying protobuf Label message. + */ + public function getProto(): LabelProto + { + return $this->label; + } +} diff --git a/src/LabelLibrary.php b/src/LabelLibrary.php new file mode 100644 index 0000000..f13c89a --- /dev/null +++ b/src/LabelLibrary.php @@ -0,0 +1,83 @@ + */ + private array $labelsByName = []; + + /** @var array */ + private array $labelsByNameLower = []; + + public function __construct( + private readonly ProLabelsDocument $document, + ) { + foreach ($this->document->getLabels() as $labelProto) { + $label = new Label($labelProto); + $this->labels[] = $label; + + $name = $label->getName(); + if ($name === '') { + continue; + } + + $this->labelsByName[$name] ??= $label; + $this->labelsByNameLower[strtolower($name)] ??= $label; + } + } + + /** + * @return Label[] + */ + public function getLabels(): array + { + return $this->labels; + } + + public function count(): int + { + return count($this->labels); + } + + /** + * Exact-name lookup. Use {@see findLabelByName()} for a + * case-insensitive variant. + */ + public function getLabelByName(string $name): ?Label + { + return $this->labelsByName[$name] ?? null; + } + + /** + * Case-insensitive name lookup. + */ + public function findLabelByName(string $name): ?Label + { + return $this->labelsByNameLower[strtolower($name)] ?? null; + } + + /** + * Get the underlying protobuf ProLabelsDocument. + */ + public function getDocument(): ProLabelsDocument + { + return $this->document; + } +} diff --git a/src/LabelsFileReader.php b/src/LabelsFileReader.php new file mode 100644 index 0000000..e17ba2f --- /dev/null +++ b/src/LabelsFileReader.php @@ -0,0 +1,42 @@ +mergeFromString($data); + + return new LabelLibrary($document); + } +} diff --git a/tests/LabelsFileReaderTest.php b/tests/LabelsFileReaderTest.php new file mode 100644 index 0000000..e13ad5d --- /dev/null +++ b/tests/LabelsFileReaderTest.php @@ -0,0 +1,136 @@ +expectException(InvalidArgumentException::class); + LabelsFileReader::read(__DIR__ . '/../doc/reference_samples/does-not-exist-labels'); + } + + #[Test] + public function readReturnsLabelLibraryWithExpectedCount(): void + { + $library = LabelsFileReader::read(self::REFERENCE_PATH); + + $this->assertInstanceOf(LabelLibrary::class, $library); + $this->assertCount(15, $library->getLabels()); + $this->assertSame(15, $library->count()); + } + + #[Test] + public function labelsExposeNames(): void + { + $library = LabelsFileReader::read(self::REFERENCE_PATH); + $labels = $library->getLabels(); + + $this->assertInstanceOf(Label::class, $labels[0]); + $this->assertSame('Leere Folie', $labels[0]->getName()); + $this->assertSame('Instrumental', $labels[1]->getName()); + $this->assertSame('KeyVisual Stream & Beamer mit Countdown', $labels[4]->getName()); + $this->assertSame('Szene 3', $labels[14]->getName()); + } + + #[Test] + public function labelsWithoutColorReportNullColor(): void + { + $library = LabelsFileReader::read(self::REFERENCE_PATH); + + $leereFolie = $library->getLabelByName('Leere Folie'); + $this->assertNotNull($leereFolie); + $this->assertFalse($leereFolie->hasColor()); + $this->assertNull($leereFolie->getColor()); + $this->assertNull($leereFolie->getColorHex()); + } + + #[Test] + public function labelsWithColorReturnFloatChannels(): void + { + $library = LabelsFileReader::read(self::REFERENCE_PATH); + + $beamer = $library->getLabelByName('KeyVisual Beamer'); + $this->assertNotNull($beamer); + $this->assertTrue($beamer->hasColor()); + + $color = $beamer->getColor(); + $this->assertIsArray($color); + $this->assertEqualsWithDelta(0.0, $color['r'], 0.001); + $this->assertEqualsWithDelta(0.4078, $color['g'], 0.001); + $this->assertEqualsWithDelta(0.7020, $color['b'], 0.001); + $this->assertEqualsWithDelta(1.0, $color['a'], 0.001); + } + + #[Test] + public function colorHexIsSixDigitUppercase(): void + { + $library = LabelsFileReader::read(self::REFERENCE_PATH); + + $beamer = $library->getLabelByName('KeyVisual Beamer'); + $this->assertNotNull($beamer); + $this->assertSame('#0068B3', $beamer->getColorHex()); + + $countdown = $library->getLabelByName('KeyVisual Stream & Beamer mit Countdown'); + $this->assertNotNull($countdown); + $this->assertSame('#CC298B', $countdown->getColorHex()); + + $kill = $library->getLabelByName('Namenseinblender Kill'); + $this->assertNotNull($kill); + $this->assertSame('#000000', $kill->getColorHex()); + } + + #[Test] + public function getLabelByNameIsCaseSensitive(): void + { + $library = LabelsFileReader::read(self::REFERENCE_PATH); + + $this->assertNotNull($library->getLabelByName('Szene 1')); + $this->assertNull($library->getLabelByName('szene 1')); + } + + #[Test] + public function findLabelByNameIsCaseInsensitive(): void + { + $library = LabelsFileReader::read(self::REFERENCE_PATH); + + $exact = $library->findLabelByName('Szene 1'); + $lower = $library->findLabelByName('szene 1'); + $upper = $library->findLabelByName('SZENE 1'); + + $this->assertNotNull($exact); + $this->assertSame($exact, $lower); + $this->assertSame($exact, $upper); + $this->assertSame('Szene 1', $exact->getName()); + } + + #[Test] + public function unknownNameReturnsNull(): void + { + $library = LabelsFileReader::read(self::REFERENCE_PATH); + + $this->assertNull($library->getLabelByName('does-not-exist')); + $this->assertNull($library->findLabelByName('does-not-exist')); + } + + #[Test] + public function documentIsExposedForRawAccess(): void + { + $library = LabelsFileReader::read(self::REFERENCE_PATH); + + $document = $library->getDocument(); + $this->assertCount(15, $document->getLabels()); + } +}