feat: preview generation

Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
2026-09-14 18:37:58 -04:00
parent b79666566a
commit 9a767339fe
4 changed files with 262 additions and 0 deletions
+10
View File
@@ -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'; }
+45
View File
@@ -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);
}
}
}
+92
View File
@@ -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);
}
}
}