generated from Nodarx/template
feat: preview generation
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
@@ -11,9 +11,19 @@ use KTXF\Module\ModuleInstanceAbstract;
|
|||||||
use KTXM\ServiceWopi\Console\CheckCommand;
|
use KTXM\ServiceWopi\Console\CheckCommand;
|
||||||
use KTXM\ServiceWopi\Console\CleanupCommand;
|
use KTXM\ServiceWopi\Console\CleanupCommand;
|
||||||
use KTXM\ServiceWopi\Console\ConfigureCommand;
|
use KTXM\ServiceWopi\Console\ConfigureCommand;
|
||||||
|
use KTXC\Resource\ProviderManager;
|
||||||
|
use KTXF\Resource\Provider\ProviderInterface;
|
||||||
|
use KTXM\ServiceWopi\Preview\Provider;
|
||||||
|
|
||||||
class Module extends ModuleInstanceAbstract
|
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 handle(): string { return 'service_wopi'; }
|
||||||
public function label(): string { return 'WOPI Service'; }
|
public function label(): string { return 'WOPI Service'; }
|
||||||
public function author(): string { return 'Ktrix'; }
|
public function author(): string { return 'Ktrix'; }
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
namespace KTXM\ServiceWopi\Preview;
|
||||||
|
|
||||||
|
use CURLStringFile;
|
||||||
|
use KTXF\Preview\PreviewGenerationException;
|
||||||
|
use KTXM\ServiceWopi\Discovery\ServerAddress;
|
||||||
|
|
||||||
|
class ConversionClient
|
||||||
|
{
|
||||||
|
public const MAX_RESPONSE_BYTES = 32 * 1024 * 1024;
|
||||||
|
|
||||||
|
public function pdf(ServerAddress $server, string $content, string $extension, string $mimeType): string
|
||||||
|
{
|
||||||
|
$curl = curl_init($server->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
namespace KTXM\ServiceWopi\Preview;
|
||||||
|
|
||||||
|
use Imagick;
|
||||||
|
use KTXC\Context\TenantContextInterface;
|
||||||
|
use KTXF\Preview\MimeType;
|
||||||
|
use KTXF\Preview\PreviewGenerationException;
|
||||||
|
use KTXF\Preview\PreviewRequest;
|
||||||
|
use KTXF\Preview\PreviewResult;
|
||||||
|
use KTXF\Preview\PreviewSource;
|
||||||
|
use KTXF\Preview\Provider\PreviewProviderInterface;
|
||||||
|
use KTXF\Resource\BinaryResource;
|
||||||
|
use KTXF\Resource\Provider\ProviderInterface;
|
||||||
|
use KTXM\ProviderPreviewImageMagick\Provider as ImageProvider;
|
||||||
|
use KTXM\ServiceWopi\Host\WopiService;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
final class Provider implements PreviewProviderInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly TenantContextInterface $tenant,
|
||||||
|
private readonly WopiService $wopi,
|
||||||
|
private readonly ConversionClient $client,
|
||||||
|
private readonly ImageProvider $images,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function type(): string { return ProviderInterface::TYPE_PREVIEW; }
|
||||||
|
public function identifier(): string { return 'wopi'; }
|
||||||
|
public function label(): string { return 'WOPI Office'; }
|
||||||
|
public function description(): string { return 'Generates first-page previews using the configured office server'; }
|
||||||
|
public function icon(): string { return 'file-document'; }
|
||||||
|
public function priority(): int { return 100; }
|
||||||
|
|
||||||
|
public function supports(string $sourceMimeType, PreviewRequest $request): bool
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
if (!class_exists(Imagick::class)
|
||||||
|
|| !in_array(MimeType::normalize($sourceMimeType), WopiService::FORMATS, true)
|
||||||
|
|| !$this->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use KTXC\Context\TenantContextInterface;
|
||||||
|
use KTXC\Resource\ProviderManager;
|
||||||
|
use KTXF\Preview\PreviewGenerationException;
|
||||||
|
use KTXF\Preview\PreviewRequest;
|
||||||
|
use KTXF\Preview\PreviewSource;
|
||||||
|
use KTXF\Resource\BinaryResource;
|
||||||
|
use KTXF\Resource\Provider\ProviderInterface;
|
||||||
|
use KTXM\ProviderPreviewImageMagick\Provider as ImageProvider;
|
||||||
|
use KTXM\ServiceWopi\Host\Configuration;
|
||||||
|
use KTXM\ServiceWopi\Host\WopiService;
|
||||||
|
use KTXM\ServiceWopi\Module;
|
||||||
|
use KTXM\ServiceWopi\Preview\ConversionClient;
|
||||||
|
use KTXM\ServiceWopi\Preview\Provider;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class PreviewTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testRegistersPreviewProvider(): void
|
||||||
|
{
|
||||||
|
$registry = $this->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; })()));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user