diff --git a/shared/lib/Preview/MimeType.php b/shared/lib/Preview/MimeType.php new file mode 100644 index 0000000..2a21a52 --- /dev/null +++ b/shared/lib/Preview/MimeType.php @@ -0,0 +1,32 @@ +variant) !== 1) { + throw new InvalidArgumentException('Preview variant must use lowercase letters, numbers, and hyphens'); + } + if ($this->maxWidth < 1 || $this->maxHeight < 1) { + throw new InvalidArgumentException('Preview dimensions must be positive'); + } + if ($this->quality < 1 || $this->quality > 100) { + throw new InvalidArgumentException('Preview quality must be between 1 and 100'); + } + if ($this->page !== null && $this->page < 1) { + throw new InvalidArgumentException('Preview page numbers start at 1'); + } + if ($this->timestamp !== null && $this->timestamp < 0) { + throw new InvalidArgumentException('Preview timestamps cannot be negative'); + } + $this->preferredMimeType = MimeType::normalize($preferredMimeType); + } + + /** + * Fixed-order scalar representation for cache-key generation. + * + * @return array{variant:string,maxWidth:int,maxHeight:int,preferredMimeType:string,quality:int,page:?int,timestamp:?float} + */ + public function normalized(): array + { + return [ + 'variant' => $this->variant, + 'maxWidth' => $this->maxWidth, + 'maxHeight' => $this->maxHeight, + 'preferredMimeType' => $this->preferredMimeType, + 'quality' => $this->quality, + 'page' => $this->page, + 'timestamp' => $this->timestamp, + ]; + } +} diff --git a/shared/lib/Preview/PreviewResult.php b/shared/lib/Preview/PreviewResult.php new file mode 100644 index 0000000..075695a --- /dev/null +++ b/shared/lib/Preview/PreviewResult.php @@ -0,0 +1,25 @@ +resource->mimeType()); + if ($this->width !== null && $this->width < 1) { + throw new InvalidArgumentException('Preview result width must be positive'); + } + if ($this->height !== null && $this->height < 1) { + throw new InvalidArgumentException('Preview result height must be positive'); + } + } +} diff --git a/shared/lib/Preview/PreviewSource.php b/shared/lib/Preview/PreviewSource.php new file mode 100644 index 0000000..a81e406 --- /dev/null +++ b/shared/lib/Preview/PreviewSource.php @@ -0,0 +1,47 @@ +sourceType === '' || $this->identity === '' || $this->signature === '') { + throw new InvalidArgumentException('Preview sources require a type, identity, and content signature'); + } + if ($this->size !== null && $this->size < 0) { + throw new InvalidArgumentException('Preview source size cannot be negative'); + } + if ($this->selector === '') { + throw new InvalidArgumentException('Preview source selectors must be null or non-empty'); + } + $this->mimeType = MimeType::normalize($mimeType); + } + + public function open(): BinaryResource + { + $resource = ($this->resourceFactory)(); + if (!$resource instanceof BinaryResource) { + throw new PreviewException('Preview source factory did not return a BinaryResource'); + } + return $resource; + } +} diff --git a/shared/lib/Preview/Provider/PreviewProviderInterface.php b/shared/lib/Preview/Provider/PreviewProviderInterface.php new file mode 100644 index 0000000..3ed9925 --- /dev/null +++ b/shared/lib/Preview/Provider/PreviewProviderInterface.php @@ -0,0 +1,27 @@ + Exact MIME types or type wildcards such as image/*. */ + public function supportedMimeTypes(): array; + + /** @return list */ + public function outputMimeTypes(): array; + + public function supports(string $sourceMimeType, PreviewRequest $request): bool; + + public function generate(PreviewSource $source, PreviewRequest $request): PreviewResult; + + public function generatorVersion(): string; + + public function priority(): int; +} diff --git a/shared/lib/Preview/UnsupportedPreviewException.php b/shared/lib/Preview/UnsupportedPreviewException.php new file mode 100644 index 0000000..ef38cad --- /dev/null +++ b/shared/lib/Preview/UnsupportedPreviewException.php @@ -0,0 +1,9 @@ +isSubclassOf(ProviderInterface::class)); + } + + #[Test] + public function normalizesMimeTypesAndMatchesWildcards(): void + { + self::assertSame('image/jpeg', MimeType::normalize(' Image/JPEG; charset=binary ')); + self::assertTrue(MimeType::matches('image/*', 'IMAGE/WEBP')); + self::assertTrue(MimeType::matches('application/pdf', 'application/pdf')); + self::assertFalse(MimeType::matches('image/*', 'application/pdf')); + } + + #[Test] + public function requestHasADeterministicNormalizedRepresentation(): void + { + $request = new PreviewRequest('popover', 640, 440, 'IMAGE/WEBP', 80, page: 1); + + self::assertSame([ + 'variant' => 'popover', + 'maxWidth' => 640, + 'maxHeight' => 440, + 'preferredMimeType' => 'image/webp', + 'quality' => 80, + 'page' => 1, + 'timestamp' => null, + ], $request->normalized()); + } + + #[Test] + public function sourceDoesNotOpenUntilRequested(): void + { + $opened = 0; + $source = new PreviewSource( + sourceType: 'document', + identity: 'provider/service/entity', + signature: 'revision-1', + mimeType: 'image/jpeg', + size: 7, + resourceFactory: function () use (&$opened): BinaryResource { + $opened++; + return $this->resource('source'); + }, + ); + + self::assertSame(0, $opened); + self::assertInstanceOf(BinaryResource::class, $source->open()); + self::assertSame(1, $opened); + } + + #[Test] + public function sourceRejectsAnInvalidFactoryResult(): void + { + $source = new PreviewSource('document', 'entity', 'revision', 'image/jpeg', 1, fn() => 'invalid'); + $this->expectException(PreviewException::class); + $source->open(); + } + + #[Test] + public function resultUsesItsBinaryResourceMimeType(): void + { + $result = new PreviewResult($this->resource('preview'), 320, 200); + + self::assertSame('image/webp', $result->resource->mimeType()); + self::assertSame(320, $result->width); + self::assertSame(200, $result->height); + } + + private function resource(string $content): BinaryResource + { + return new BinaryResource( + 'preview.webp', + 'image/webp', + (function () use ($content): \Generator { yield $content; })(), + ); + } +}