From 9a767339fe01a251470c615ac6fd1f7a7bc246c7 Mon Sep 17 00:00:00 2001 From: Sebastian Krupinski Date: Mon, 14 Sep 2026 18:37:58 -0400 Subject: [PATCH] feat: preview generation Signed-off-by: Sebastian Krupinski --- lib/Module.php | 10 +++ lib/Preview/ConversionClient.php | 45 ++++++++++++ lib/Preview/Provider.php | 92 +++++++++++++++++++++++++ tests/php/Unit/PreviewTest.php | 115 +++++++++++++++++++++++++++++++ 4 files changed, 262 insertions(+) create mode 100644 lib/Preview/ConversionClient.php create mode 100644 lib/Preview/Provider.php create mode 100644 tests/php/Unit/PreviewTest.php diff --git a/lib/Module.php b/lib/Module.php index 1b975f4..2e9d858 100644 --- a/lib/Module.php +++ b/lib/Module.php @@ -11,9 +11,19 @@ use KTXF\Module\ModuleInstanceAbstract; use KTXM\ServiceWopi\Console\CheckCommand; use KTXM\ServiceWopi\Console\CleanupCommand; use KTXM\ServiceWopi\Console\ConfigureCommand; +use KTXC\Resource\ProviderManager; +use KTXF\Resource\Provider\ProviderInterface; +use KTXM\ServiceWopi\Preview\Provider; class Module extends ModuleInstanceAbstract { + public function __construct(private readonly ProviderManager $providers) {} + + public function boot(): void + { + $this->providers->register(ProviderInterface::TYPE_PREVIEW, 'wopi', Provider::class); + } + public function handle(): string { return 'service_wopi'; } public function label(): string { return 'WOPI Service'; } public function author(): string { return 'Ktrix'; } diff --git a/lib/Preview/ConversionClient.php b/lib/Preview/ConversionClient.php new file mode 100644 index 0000000..5e72487 --- /dev/null +++ b/lib/Preview/ConversionClient.php @@ -0,0 +1,45 @@ +url . '/cool/convert-to/pdf'); + $pdf = ''; + curl_setopt_array($curl, [ + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => ['data' => new CURLStringFile($content, 'document.' . $extension, $mimeType)], + CURLOPT_CONNECTTIMEOUT => 5, + CURLOPT_TIMEOUT => 30, + CURLOPT_FOLLOWLOCATION => false, + CURLOPT_PROTOCOLS => CURLPROTO_HTTPS, + CURLOPT_SSL_VERIFYPEER => true, + CURLOPT_SSL_VERIFYHOST => 2, + CURLOPT_HTTPHEADER => ['Accept: application/pdf'], + CURLOPT_WRITEFUNCTION => static function ($handle, string $chunk) use (&$pdf): int { + if (strlen($pdf) + strlen($chunk) > self::MAX_RESPONSE_BYTES) { return 0; } + $pdf .= $chunk; + return strlen($chunk); + }, + ]); + try { + if (curl_exec($curl) === false) { + throw new PreviewGenerationException('WOPI conversion failed (cURL ' . curl_errno($curl) . ')'); + } + if (curl_getinfo($curl, CURLINFO_RESPONSE_CODE) !== 200 || !str_starts_with($pdf, '%PDF-')) { + throw new PreviewGenerationException('WOPI conversion did not return a PDF'); + } + return $pdf; + } finally { + curl_close($curl); + } + } +} diff --git a/lib/Preview/Provider.php b/lib/Preview/Provider.php new file mode 100644 index 0000000..b87238d --- /dev/null +++ b/lib/Preview/Provider.php @@ -0,0 +1,92 @@ +images->supports('image/png', $request)) { + return false; + } + $this->wopi->configuration($this->tenant->requireIdentifier()); + return true; + } catch (Throwable) { + return false; + } + } + + public function generate(PreviewSource $source, PreviewRequest $request): PreviewResult + { + if (!$this->supports($source->mimeType, $request)) { + throw new PreviewGenerationException('WOPI does not support the requested preview conversion'); + } + $config = $this->wopi->configuration($this->tenant->requireIdentifier()); + $limit = min($request->maxSourceSize, $config->maxBytes); + if ($source->size !== null && $source->size > $limit) { + throw new PreviewGenerationException('Preview source exceeds the configured size limit'); + } + $content = ''; + foreach ($source->open()->stream() as $chunk) { + if (!is_string($chunk) || strlen($content) + strlen($chunk) > $limit) { + throw new PreviewGenerationException('Invalid or oversized preview source'); + } + $content .= $chunk; + } + if ($content === '') { throw new PreviewGenerationException('Preview source is empty'); } + + $pdf = $this->client->pdf($config->office, $content, array_search($source->mimeType, WopiService::FORMATS, true), $source->mimeType); + $file = tmpfile(); + if ($file === false) { throw new PreviewGenerationException('Cannot create preview temporary file'); } + $image = new Imagick(); + try { + if (fwrite($file, $pdf) !== strlen($pdf)) { + throw new PreviewGenerationException('Cannot write preview temporary file'); + } + $image->setResolution(96, 96); + $image->readImage('pdf:' . stream_get_meta_data($file)['uri'] . '[0]'); + $image->setImageFormat('png'); + $png = $image->getImageBlob(); + return $this->images->generate(new PreviewSource( + $source->sourceType, $source->identity, $source->signature, 'image/png', strlen($png), + static fn () => new BinaryResource('page.png', 'image/png', (static function () use ($png): \Generator { yield $png; })()), + ), new PreviewRequest($request->maxWidth, $request->maxHeight, $request->preferredMimeType, $request->quality, ConversionClient::MAX_RESPONSE_BYTES)); + } catch (Throwable $error) { + throw new PreviewGenerationException('Could not render the office preview', previous: $error); + } finally { + $image->clear(); + fclose($file); + } + } +} diff --git a/tests/php/Unit/PreviewTest.php b/tests/php/Unit/PreviewTest.php new file mode 100644 index 0000000..a274b0b --- /dev/null +++ b/tests/php/Unit/PreviewTest.php @@ -0,0 +1,115 @@ +createMock(ProviderManager::class); + $registry->expects(self::once())->method('register')->with(ProviderInterface::TYPE_PREVIEW, 'wopi', Provider::class); + (new Module($registry))->boot(); + } + + public function testSupportedFormatsAndOutputs(): void + { + $provider = $this->provider($this->createStub(ConversionClient::class)); + foreach (WopiService::FORMATS as $mime) { + self::assertTrue($provider->supports($mime, $this->request())); + } + self::assertFalse($provider->supports('application/pdf', $this->request())); + self::assertFalse($provider->supports('invalid', $this->request())); + self::assertFalse($provider->supports(WopiService::FORMATS['docx'], new PreviewRequest(100, 100, 'application/pdf', 80))); + } + + public function testDisabledWopiDoesNotOfferPreviews(): void + { + $tenant = $this->createStub(TenantContextInterface::class); + $tenant->method('requireIdentifier')->willReturn('tenant'); + $wopi = $this->createStub(WopiService::class); + $wopi->method('configuration')->willThrowException(new \KTXM\ServiceWopi\Host\HostException('Disabled', 503)); + $provider = new Provider($tenant, $wopi, $this->createStub(ConversionClient::class), new ImageProvider()); + self::assertFalse($provider->supports(WopiService::FORMATS['docx'], $this->request())); + } + + public function testRejectsStreamExceedingTenantLimitBeforeUpload(): void + { + $client = $this->createMock(ConversionClient::class); + $client->expects(self::never())->method('pdf'); + $this->expectException(PreviewGenerationException::class); + $this->provider($client, 3)->generate($this->source('oversized'), $this->request()); + } + + public function testConversionFailurePropagatesForManagerFallback(): void + { + $client = $this->createMock(ConversionClient::class); + $client->expects(self::once())->method('pdf')->willThrowException(new PreviewGenerationException('Unavailable')); + $this->expectException(PreviewGenerationException::class); + $this->provider($client)->generate($this->source('document'), $this->request()); + } + + public function testRendersOnlyFirstPageAndResizesToRequestedOutput(): void + { + // Two differently shaped, colored pages make selecting the wrong page observable. + $pdf = new Imagick(); + foreach ([[400, 200, 'red'], [200, 400, 'blue']] as [$width, $height, $color]) { + $page = new Imagick(); + $page->newImage($width, $height, $color, 'pdf'); + $pdf->addImage($page); + $page->clear(); + } + $bytes = $pdf->getImagesBlob(); + $pdf->clear(); + $client = $this->createMock(ConversionClient::class); + $client->expects(self::once())->method('pdf')->with( + self::callback(fn ($server) => $server->url === 'https://office.test'), + 'document', 'docx', WopiService::FORMATS['docx'], + )->willReturn($bytes); + $result = $this->provider($client)->generate($this->source('document'), $this->request()); + self::assertSame(100, $result->width); + self::assertSame(50, $result->height); + self::assertSame('image/webp', $result->resource->mimeType()); + $output = implode('', iterator_to_array($result->resource->stream())); + $image = new Imagick(); + $image->readImageBlob($output); + $color = $image->getImagePixelColor(20, 20)->getColor(); + self::assertGreaterThan(200, $color['r']); + self::assertLessThan(30, $color['b']); + $image->clear(); + } + + private function provider(ConversionClient $client, int $limit = 1048576): Provider + { + $tenant = $this->createStub(TenantContextInterface::class); + $tenant->method('requireIdentifier')->willReturn('tenant'); + $wopi = $this->createStub(WopiService::class); + $wopi->method('configuration')->willReturn(new Configuration([ + 'enabled' => true, 'office_server' => 'https://office.test', + 'origin_server' => 'https://app.test', 'max_bytes' => $limit, + ])); + return new Provider($tenant, $wopi, $client, new ImageProvider()); + } + + private function request(): PreviewRequest { return new PreviewRequest(100, 100, 'image/webp', 80); } + + private function source(string $content): PreviewSource + { + return new PreviewSource('document', 'file', 'version', WopiService::FORMATS['docx'], null, + static fn () => new BinaryResource('file.docx', WopiService::FORMATS['docx'], + (static function () use ($content): Generator { yield $content; })())); + } +}