aboutsummaryrefslogtreecommitdiffstats
path: root/lib/private/Preview
diff options
context:
space:
mode:
Diffstat (limited to 'lib/private/Preview')
-rw-r--r--lib/private/Preview/BMP.php17
-rw-r--r--lib/private/Preview/BackgroundCleanupJob.php201
-rw-r--r--lib/private/Preview/Bitmap.php128
-rw-r--r--lib/private/Preview/Bundled.php43
-rw-r--r--lib/private/Preview/EMF.php16
-rw-r--r--lib/private/Preview/Font.php25
-rw-r--r--lib/private/Preview/GIF.php17
-rw-r--r--lib/private/Preview/Generator.php616
-rw-r--r--lib/private/Preview/GeneratorHelper.php69
-rw-r--r--lib/private/Preview/HEIC.php152
-rw-r--r--lib/private/Preview/IMagickSupport.php44
-rw-r--r--lib/private/Preview/Illustrator.php25
-rw-r--r--lib/private/Preview/Image.php50
-rw-r--r--lib/private/Preview/Imaginary.php191
-rw-r--r--lib/private/Preview/ImaginaryPDF.php14
-rw-r--r--lib/private/Preview/JPEG.php17
-rw-r--r--lib/private/Preview/Krita.php35
-rw-r--r--lib/private/Preview/MP3.php65
-rw-r--r--lib/private/Preview/MSOffice2003.php18
-rw-r--r--lib/private/Preview/MSOffice2007.php18
-rw-r--r--lib/private/Preview/MSOfficeDoc.php18
-rw-r--r--lib/private/Preview/MarkDown.php127
-rw-r--r--lib/private/Preview/MimeIconProvider.php83
-rw-r--r--lib/private/Preview/Movie.php194
-rw-r--r--lib/private/Preview/Office.php96
-rw-r--r--lib/private/Preview/OpenDocument.php33
-rw-r--r--lib/private/Preview/PDF.php25
-rw-r--r--lib/private/Preview/PNG.php17
-rw-r--r--lib/private/Preview/Photoshop.php25
-rw-r--r--lib/private/Preview/Postscript.php25
-rw-r--r--lib/private/Preview/Provider.php50
-rw-r--r--lib/private/Preview/ProviderV1Adapter.php45
-rw-r--r--lib/private/Preview/ProviderV2.php108
-rw-r--r--lib/private/Preview/SGI.php24
-rw-r--r--lib/private/Preview/SVG.php70
-rw-r--r--lib/private/Preview/StarOffice.php18
-rw-r--r--lib/private/Preview/Storage/Root.php74
-rw-r--r--lib/private/Preview/TGA.php24
-rw-r--r--lib/private/Preview/TIFF.php25
-rw-r--r--lib/private/Preview/TXT.php89
-rw-r--r--lib/private/Preview/Watcher.php63
-rw-r--r--lib/private/Preview/WatcherConnector.php41
-rw-r--r--lib/private/Preview/WebP.php24
-rw-r--r--lib/private/Preview/XBitmap.php17
44 files changed, 3076 insertions, 0 deletions
diff --git a/lib/private/Preview/BMP.php b/lib/private/Preview/BMP.php
new file mode 100644
index 00000000000..f275aecf0cf
--- /dev/null
+++ b/lib/private/Preview/BMP.php
@@ -0,0 +1,17 @@
+<?php
+
+/**
+ * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+namespace OC\Preview;
+
+class BMP extends Image {
+ /**
+ * {@inheritDoc}
+ */
+ public function getMimeType(): string {
+ return '/image\/bmp/';
+ }
+}
diff --git a/lib/private/Preview/BackgroundCleanupJob.php b/lib/private/Preview/BackgroundCleanupJob.php
new file mode 100644
index 00000000000..3138abb1bf9
--- /dev/null
+++ b/lib/private/Preview/BackgroundCleanupJob.php
@@ -0,0 +1,201 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OC\Preview;
+
+use OC\Preview\Storage\Root;
+use OCP\AppFramework\Utility\ITimeFactory;
+use OCP\BackgroundJob\TimedJob;
+use OCP\DB\QueryBuilder\IQueryBuilder;
+use OCP\Files\IMimeTypeLoader;
+use OCP\Files\NotFoundException;
+use OCP\Files\NotPermittedException;
+use OCP\IDBConnection;
+
+class BackgroundCleanupJob extends TimedJob {
+
+ public function __construct(
+ ITimeFactory $timeFactory,
+ private IDBConnection $connection,
+ private Root $previewFolder,
+ private IMimeTypeLoader $mimeTypeLoader,
+ private bool $isCLI,
+ ) {
+ parent::__construct($timeFactory);
+ // Run at most once an hour
+ $this->setInterval(60 * 60);
+ $this->setTimeSensitivity(self::TIME_INSENSITIVE);
+ }
+
+ public function run($argument) {
+ foreach ($this->getDeletedFiles() as $fileId) {
+ try {
+ $preview = $this->previewFolder->getFolder((string)$fileId);
+ $preview->delete();
+ } catch (NotFoundException $e) {
+ // continue
+ } catch (NotPermittedException $e) {
+ // continue
+ }
+ }
+ }
+
+ private function getDeletedFiles(): \Iterator {
+ yield from $this->getOldPreviewLocations();
+ yield from $this->getNewPreviewLocations();
+ }
+
+ private function getOldPreviewLocations(): \Iterator {
+ if ($this->connection->getShardDefinition('filecache')) {
+ // sharding is new enough that we don't need to support this
+ return;
+ }
+
+ $qb = $this->connection->getQueryBuilder();
+ $qb->select('a.name')
+ ->from('filecache', 'a')
+ ->leftJoin('a', 'filecache', 'b', $qb->expr()->eq(
+ $qb->expr()->castColumn('a.name', IQueryBuilder::PARAM_INT), 'b.fileid'
+ ))
+ ->where(
+ $qb->expr()->andX(
+ $qb->expr()->isNull('b.fileid'),
+ $qb->expr()->eq('a.storage', $qb->createNamedParameter($this->previewFolder->getStorageId())),
+ $qb->expr()->eq('a.parent', $qb->createNamedParameter($this->previewFolder->getId())),
+ $qb->expr()->like('a.name', $qb->createNamedParameter('__%')),
+ $qb->expr()->eq('a.mimetype', $qb->createNamedParameter($this->mimeTypeLoader->getId('httpd/unix-directory')))
+ )
+ );
+
+ if (!$this->isCLI) {
+ $qb->setMaxResults(10);
+ }
+
+ $cursor = $qb->executeQuery();
+
+ while ($row = $cursor->fetch()) {
+ yield $row['name'];
+ }
+
+ $cursor->closeCursor();
+ }
+
+ private function getNewPreviewLocations(): \Iterator {
+ $qb = $this->connection->getQueryBuilder();
+ $qb->select('path', 'mimetype')
+ ->from('filecache')
+ ->where($qb->expr()->eq('fileid', $qb->createNamedParameter($this->previewFolder->getId())));
+ $cursor = $qb->executeQuery();
+ $data = $cursor->fetch();
+ $cursor->closeCursor();
+
+ if ($data === null) {
+ return [];
+ }
+
+ if ($this->connection->getShardDefinition('filecache')) {
+ $chunks = $this->getAllPreviewIds($data['path'], 1000);
+ foreach ($chunks as $chunk) {
+ yield from $this->findMissingSources($chunk);
+ }
+
+ return;
+ }
+
+ /*
+ * This lovely like is the result of the way the new previews are stored
+ * We take the md5 of the name (fileid) and split the first 7 chars. That way
+ * there are not a gazillion files in the root of the preview appdata.
+ */
+ $like = $this->connection->escapeLikeParameter($data['path']) . '/_/_/_/_/_/_/_/%';
+
+ /*
+ * Deleting a file will not delete related previews right away.
+ *
+ * A delete request is usually an HTTP request.
+ * The preview deleting is done by a background job to avoid timeouts.
+ *
+ * Previews for a file are stored within a folder in appdata_/preview using the fileid as folder name.
+ * Preview folders in oc_filecache are identified by a.storage, a.path (cf. $like) and a.mimetype.
+ *
+ * To find preview folders to delete, we query oc_filecache for a preview folder in app data, matching the preview folder structure
+ * and use the name to left join oc_filecache on a.name = b.fileid. A left join returns all rows from the left table (a),
+ * even if there are no matches in the right table (b).
+ *
+ * If the related file is deleted, b.fileid will be null and the preview folder can be deleted.
+ */
+ $qb = $this->connection->getQueryBuilder();
+ $qb->select('a.name')
+ ->from('filecache', 'a')
+ ->leftJoin('a', 'filecache', 'b', $qb->expr()->eq(
+ $qb->expr()->castColumn('a.name', IQueryBuilder::PARAM_INT), 'b.fileid'
+ ))
+ ->where(
+ $qb->expr()->andX(
+ $qb->expr()->eq('a.storage', $qb->createNamedParameter($this->previewFolder->getStorageId())),
+ $qb->expr()->isNull('b.fileid'),
+ $qb->expr()->like('a.path', $qb->createNamedParameter($like)),
+ $qb->expr()->eq('a.mimetype', $qb->createNamedParameter($this->mimeTypeLoader->getId('httpd/unix-directory')))
+ )
+ );
+
+ if (!$this->isCLI) {
+ $qb->setMaxResults(10);
+ }
+
+ $cursor = $qb->executeQuery();
+
+ while ($row = $cursor->fetch()) {
+ yield $row['name'];
+ }
+
+ $cursor->closeCursor();
+ }
+
+ private function getAllPreviewIds(string $previewRoot, int $chunkSize): \Iterator {
+ // See `getNewPreviewLocations` for some more info about the logic here
+ $like = $this->connection->escapeLikeParameter($previewRoot) . '/_/_/_/_/_/_/_/%';
+
+ $qb = $this->connection->getQueryBuilder();
+ $qb->select('name', 'fileid')
+ ->from('filecache')
+ ->where(
+ $qb->expr()->andX(
+ $qb->expr()->eq('storage', $qb->createNamedParameter($this->previewFolder->getStorageId())),
+ $qb->expr()->like('path', $qb->createNamedParameter($like)),
+ $qb->expr()->eq('mimetype', $qb->createNamedParameter($this->mimeTypeLoader->getId('httpd/unix-directory'))),
+ $qb->expr()->gt('fileid', $qb->createParameter('min_id')),
+ )
+ )
+ ->orderBy('fileid', 'ASC')
+ ->setMaxResults($chunkSize);
+
+ $minId = 0;
+ while (true) {
+ $qb->setParameter('min_id', $minId);
+ $rows = $qb->executeQuery()->fetchAll();
+ if (count($rows) > 0) {
+ $minId = $rows[count($rows) - 1]['fileid'];
+ yield array_map(function ($row) {
+ return (int)$row['name'];
+ }, $rows);
+ } else {
+ break;
+ }
+ }
+ }
+
+ private function findMissingSources(array $ids): array {
+ $qb = $this->connection->getQueryBuilder();
+ $qb->select('fileid')
+ ->from('filecache')
+ ->where($qb->expr()->in('fileid', $qb->createNamedParameter($ids, IQueryBuilder::PARAM_INT_ARRAY)));
+ $found = $qb->executeQuery()->fetchAll(\PDO::FETCH_COLUMN);
+ return array_diff($ids, $found);
+ }
+}
diff --git a/lib/private/Preview/Bitmap.php b/lib/private/Preview/Bitmap.php
new file mode 100644
index 00000000000..a3d5fbfd4ec
--- /dev/null
+++ b/lib/private/Preview/Bitmap.php
@@ -0,0 +1,128 @@
+<?php
+
+/**
+ * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+namespace OC\Preview;
+
+use Imagick;
+use OCP\Files\File;
+use OCP\IImage;
+use Psr\Log\LoggerInterface;
+
+/**
+ * Creates a PNG preview using ImageMagick via the PECL extension
+ *
+ * @package OC\Preview
+ */
+abstract class Bitmap extends ProviderV2 {
+ /**
+ * List of MIME types that this preview provider is allowed to process.
+ *
+ * These should correspond to the MIME types *identified* by Imagemagick
+ * for files to be processed by this provider. These do / will not
+ * necessarily need to match the MIME types stored in the database
+ * (which are identified by IMimeTypeDetector).
+ *
+ * @return string Regular expression
+ */
+ abstract protected function getAllowedMimeTypes(): string;
+
+ /**
+ * {@inheritDoc}
+ */
+ public function getThumbnail(File $file, int $maxX, int $maxY): ?IImage {
+ $tmpPath = $this->getLocalFile($file);
+ if ($tmpPath === false) {
+ \OC::$server->get(LoggerInterface::class)->error(
+ 'Failed to get thumbnail for: ' . $file->getPath(),
+ ['app' => 'core']
+ );
+ return null;
+ }
+
+ // Creates \Imagick object from bitmap or vector file
+ try {
+ $bp = $this->getResizedPreview($tmpPath, $maxX, $maxY);
+ } catch (\Exception $e) {
+ \OC::$server->get(LoggerInterface::class)->info(
+ 'File: ' . $file->getPath() . ' Imagick says:',
+ [
+ 'exception' => $e,
+ 'app' => 'core',
+ ]
+ );
+ return null;
+ }
+
+ $this->cleanTmpFiles();
+
+ //new bitmap image object
+ $image = new \OCP\Image();
+ $image->loadFromData((string)$bp);
+ //check if image object is valid
+ return $image->valid() ? $image : null;
+ }
+
+ /**
+ * Returns a preview of maxX times maxY dimensions in PNG format
+ *
+ * * The default resolution is already 72dpi, no need to change it for a bitmap output
+ * * It's possible to have proper colour conversion using profileimage().
+ * ICC profiles are here: http://www.color.org/srgbprofiles.xalter
+ * * It's possible to Gamma-correct an image via gammaImage()
+ *
+ * @param string $tmpPath the location of the file to convert
+ * @param int $maxX
+ * @param int $maxY
+ *
+ * @return \Imagick
+ *
+ * @throws \Exception
+ */
+ private function getResizedPreview($tmpPath, $maxX, $maxY) {
+ $bp = new Imagick();
+
+ // Validate mime type
+ $bp->pingImage($tmpPath . '[0]');
+ $mimeType = $bp->getImageMimeType();
+ if (!preg_match($this->getAllowedMimeTypes(), $mimeType)) {
+ throw new \Exception('File mime type does not match the preview provider: ' . $mimeType);
+ }
+
+ // Layer 0 contains either the bitmap or a flat representation of all vector layers
+ $bp->readImage($tmpPath . '[0]');
+
+ $bp = $this->resize($bp, $maxX, $maxY);
+
+ $bp->setImageFormat('png');
+
+ return $bp;
+ }
+
+ /**
+ * Returns a resized \Imagick object
+ *
+ * If you want to know more on the various methods available to resize an
+ * image, check out this link : @link https://stackoverflow.com/questions/8517304/what-the-difference-of-sample-resample-scale-resize-adaptive-resize-thumbnail-im
+ *
+ * @param \Imagick $bp
+ * @param int $maxX
+ * @param int $maxY
+ *
+ * @return \Imagick
+ */
+ private function resize($bp, $maxX, $maxY) {
+ [$previewWidth, $previewHeight] = array_values($bp->getImageGeometry());
+
+ // We only need to resize a preview which doesn't fit in the maximum dimensions
+ if ($previewWidth > $maxX || $previewHeight > $maxY) {
+ // TODO: LANCZOS is the default filter, CATROM could bring similar results faster
+ $bp->resizeImage($maxX, $maxY, imagick::FILTER_LANCZOS, 1, true);
+ }
+
+ return $bp;
+ }
+}
diff --git a/lib/private/Preview/Bundled.php b/lib/private/Preview/Bundled.php
new file mode 100644
index 00000000000..6100e8262a4
--- /dev/null
+++ b/lib/private/Preview/Bundled.php
@@ -0,0 +1,43 @@
+<?php
+
+/**
+ * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OC\Preview;
+
+use OC\Archive\ZIP;
+use OCP\Files\File;
+use OCP\IImage;
+
+/**
+ * Extracts a preview from files that embed them in an ZIP archive
+ */
+abstract class Bundled extends ProviderV2 {
+ protected function extractThumbnail(File $file, string $path): ?IImage {
+ if ($file->getSize() === 0) {
+ return null;
+ }
+
+ $sourceTmp = \OC::$server->getTempManager()->getTemporaryFile();
+ $targetTmp = \OC::$server->getTempManager()->getTemporaryFile();
+ $this->tmpFiles[] = $sourceTmp;
+ $this->tmpFiles[] = $targetTmp;
+
+ try {
+ $content = $file->fopen('r');
+ file_put_contents($sourceTmp, $content);
+
+ $zip = new ZIP($sourceTmp);
+ $zip->extractFile($path, $targetTmp);
+
+ $image = new \OCP\Image();
+ $image->loadFromFile($targetTmp);
+ $image->fixOrientation();
+
+ return $image;
+ } catch (\Throwable $e) {
+ return null;
+ }
+ }
+}
diff --git a/lib/private/Preview/EMF.php b/lib/private/Preview/EMF.php
new file mode 100644
index 00000000000..a3c48e7d8aa
--- /dev/null
+++ b/lib/private/Preview/EMF.php
@@ -0,0 +1,16 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OC\Preview;
+
+class EMF extends Office {
+ public function getMimeType(): string {
+ return '/image\/emf/';
+ }
+}
diff --git a/lib/private/Preview/Font.php b/lib/private/Preview/Font.php
new file mode 100644
index 00000000000..79e537f6ffb
--- /dev/null
+++ b/lib/private/Preview/Font.php
@@ -0,0 +1,25 @@
+<?php
+
+/**
+ * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+namespace OC\Preview;
+
+// .otf, .ttf and .pfb
+class Font extends Bitmap {
+ /**
+ * {@inheritDoc}
+ */
+ public function getMimeType(): string {
+ return '/application\/(?:font-sfnt|x-font$)/';
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ protected function getAllowedMimeTypes(): string {
+ return '/(application|image)\/(?:font-sfnt|x-font|x-otf|x-ttf|x-pfb$)/';
+ }
+}
diff --git a/lib/private/Preview/GIF.php b/lib/private/Preview/GIF.php
new file mode 100644
index 00000000000..941ef68648a
--- /dev/null
+++ b/lib/private/Preview/GIF.php
@@ -0,0 +1,17 @@
+<?php
+
+/**
+ * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+namespace OC\Preview;
+
+class GIF extends Image {
+ /**
+ * {@inheritDoc}
+ */
+ public function getMimeType(): string {
+ return '/image\/gif/';
+ }
+}
diff --git a/lib/private/Preview/Generator.php b/lib/private/Preview/Generator.php
new file mode 100644
index 00000000000..4a7341896ef
--- /dev/null
+++ b/lib/private/Preview/Generator.php
@@ -0,0 +1,616 @@
+<?php
+
+/**
+ * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OC\Preview;
+
+use OCP\EventDispatcher\IEventDispatcher;
+use OCP\Files\File;
+use OCP\Files\IAppData;
+use OCP\Files\InvalidPathException;
+use OCP\Files\NotFoundException;
+use OCP\Files\NotPermittedException;
+use OCP\Files\SimpleFS\InMemoryFile;
+use OCP\Files\SimpleFS\ISimpleFile;
+use OCP\Files\SimpleFS\ISimpleFolder;
+use OCP\IConfig;
+use OCP\IImage;
+use OCP\IPreview;
+use OCP\IStreamImage;
+use OCP\Preview\BeforePreviewFetchedEvent;
+use OCP\Preview\IProviderV2;
+use OCP\Preview\IVersionedPreviewFile;
+use Psr\Log\LoggerInterface;
+
+class Generator {
+ public const SEMAPHORE_ID_ALL = 0x0a11;
+ public const SEMAPHORE_ID_NEW = 0x07ea;
+
+ public function __construct(
+ private IConfig $config,
+ private IPreview $previewManager,
+ private IAppData $appData,
+ private GeneratorHelper $helper,
+ private IEventDispatcher $eventDispatcher,
+ private LoggerInterface $logger,
+ ) {
+ }
+
+ /**
+ * Returns a preview of a file
+ *
+ * The cache is searched first and if nothing usable was found then a preview is
+ * generated by one of the providers
+ *
+ * @return ISimpleFile
+ * @throws NotFoundException
+ * @throws \InvalidArgumentException if the preview would be invalid (in case the original image is invalid)
+ */
+ public function getPreview(
+ File $file,
+ int $width = -1,
+ int $height = -1,
+ bool $crop = false,
+ string $mode = IPreview::MODE_FILL,
+ ?string $mimeType = null,
+ bool $cacheResult = true,
+ ): ISimpleFile {
+ $specification = [
+ 'width' => $width,
+ 'height' => $height,
+ 'crop' => $crop,
+ 'mode' => $mode,
+ ];
+
+ $this->eventDispatcher->dispatchTyped(new BeforePreviewFetchedEvent(
+ $file,
+ $width,
+ $height,
+ $crop,
+ $mode,
+ $mimeType,
+ ));
+
+ $this->logger->debug('Requesting preview for {path} with width={width}, height={height}, crop={crop}, mode={mode}, mimeType={mimeType}', [
+ 'path' => $file->getPath(),
+ 'width' => $width,
+ 'height' => $height,
+ 'crop' => $crop,
+ 'mode' => $mode,
+ 'mimeType' => $mimeType,
+ ]);
+
+
+ // since we only ask for one preview, and the generate method return the last one it created, it returns the one we want
+ return $this->generatePreviews($file, [$specification], $mimeType, $cacheResult);
+ }
+
+ /**
+ * Generates previews of a file
+ *
+ * @throws NotFoundException
+ * @throws \InvalidArgumentException if the preview would be invalid (in case the original image is invalid)
+ */
+ public function generatePreviews(File $file, array $specifications, ?string $mimeType = null, bool $cacheResult = true): ISimpleFile {
+ //Make sure that we can read the file
+ if (!$file->isReadable()) {
+ $this->logger->warning('Cannot read file: {path}, skipping preview generation.', ['path' => $file->getPath()]);
+ throw new NotFoundException('Cannot read file');
+ }
+
+ if ($mimeType === null) {
+ $mimeType = $file->getMimeType();
+ }
+
+ $previewFolder = $this->getPreviewFolder($file);
+ // List every existing preview first instead of trying to find them one by one
+ $previewFiles = $previewFolder->getDirectoryListing();
+
+ $previewVersion = '';
+ if ($file instanceof IVersionedPreviewFile) {
+ $previewVersion = $file->getPreviewVersion() . '-';
+ }
+
+ // Get the max preview and infer the max preview sizes from that
+ $maxPreview = $this->getMaxPreview($previewFolder, $previewFiles, $file, $mimeType, $previewVersion);
+ $maxPreviewImage = null; // only load the image when we need it
+ if ($maxPreview->getSize() === 0) {
+ $maxPreview->delete();
+ $this->logger->error('Max preview generated for file {path} has size 0, deleting and throwing exception.', ['path' => $file->getPath()]);
+ throw new NotFoundException('Max preview size 0, invalid!');
+ }
+
+ [$maxWidth, $maxHeight] = $this->getPreviewSize($maxPreview, $previewVersion);
+
+ if ($maxWidth <= 0 || $maxHeight <= 0) {
+ throw new NotFoundException('The maximum preview sizes are zero or less pixels');
+ }
+
+ $preview = null;
+
+ foreach ($specifications as $specification) {
+ $width = $specification['width'] ?? -1;
+ $height = $specification['height'] ?? -1;
+ $crop = $specification['crop'] ?? false;
+ $mode = $specification['mode'] ?? IPreview::MODE_FILL;
+
+ // If both width and height are -1 we just want the max preview
+ if ($width === -1 && $height === -1) {
+ $width = $maxWidth;
+ $height = $maxHeight;
+ }
+
+ // Calculate the preview size
+ [$width, $height] = $this->calculateSize($width, $height, $crop, $mode, $maxWidth, $maxHeight);
+
+ // No need to generate a preview that is just the max preview
+ if ($width === $maxWidth && $height === $maxHeight) {
+ // ensure correct return value if this was the last one
+ $preview = $maxPreview;
+ continue;
+ }
+
+ // Try to get a cached preview. Else generate (and store) one
+ try {
+ try {
+ $preview = $this->getCachedPreview($previewFiles, $width, $height, $crop, $maxPreview->getMimeType(), $previewVersion);
+ } catch (NotFoundException $e) {
+ if (!$this->previewManager->isMimeSupported($mimeType)) {
+ throw new NotFoundException();
+ }
+
+ if ($maxPreviewImage === null) {
+ $maxPreviewImage = $this->helper->getImage($maxPreview);
+ }
+
+ $this->logger->debug('Cached preview not found for file {path}, generating a new preview.', ['path' => $file->getPath()]);
+ $preview = $this->generatePreview($previewFolder, $maxPreviewImage, $width, $height, $crop, $maxWidth, $maxHeight, $previewVersion, $cacheResult);
+ // New file, augment our array
+ $previewFiles[] = $preview;
+ }
+ } catch (\InvalidArgumentException $e) {
+ throw new NotFoundException('', 0, $e);
+ }
+
+ if ($preview->getSize() === 0) {
+ $preview->delete();
+ throw new NotFoundException('Cached preview size 0, invalid!');
+ }
+ }
+ assert($preview !== null);
+
+ // Free memory being used by the embedded image resource. Without this the image is kept in memory indefinitely.
+ // Garbage Collection does NOT free this memory. We have to do it ourselves.
+ if ($maxPreviewImage instanceof \OCP\Image) {
+ $maxPreviewImage->destroy();
+ }
+
+ return $preview;
+ }
+
+ /**
+ * Acquire a semaphore of the specified id and concurrency, blocking if necessary.
+ * Return an identifier of the semaphore on success, which can be used to release it via
+ * {@see Generator::unguardWithSemaphore()}.
+ *
+ * @param int $semId
+ * @param int $concurrency
+ * @return false|\SysvSemaphore the semaphore on success or false on failure
+ */
+ public static function guardWithSemaphore(int $semId, int $concurrency) {
+ if (!extension_loaded('sysvsem')) {
+ return false;
+ }
+ $sem = sem_get($semId, $concurrency);
+ if ($sem === false) {
+ return false;
+ }
+ if (!sem_acquire($sem)) {
+ return false;
+ }
+ return $sem;
+ }
+
+ /**
+ * Releases the semaphore acquired from {@see Generator::guardWithSemaphore()}.
+ *
+ * @param false|\SysvSemaphore $semId the semaphore identifier returned by guardWithSemaphore
+ * @return bool
+ */
+ public static function unguardWithSemaphore(false|\SysvSemaphore $semId): bool {
+ if ($semId === false || !($semId instanceof \SysvSemaphore)) {
+ return false;
+ }
+ return sem_release($semId);
+ }
+
+ /**
+ * Get the number of concurrent threads supported by the host.
+ *
+ * @return int number of concurrent threads, or 0 if it cannot be determined
+ */
+ public static function getHardwareConcurrency(): int {
+ static $width;
+
+ if (!isset($width)) {
+ if (function_exists('ini_get')) {
+ $openBasedir = ini_get('open_basedir');
+ if (empty($openBasedir) || strpos($openBasedir, '/proc/cpuinfo') !== false) {
+ $width = is_readable('/proc/cpuinfo') ? substr_count(file_get_contents('/proc/cpuinfo'), 'processor') : 0;
+ } else {
+ $width = 0;
+ }
+ } else {
+ $width = 0;
+ }
+ }
+ return $width;
+ }
+
+ /**
+ * Get number of concurrent preview generations from system config
+ *
+ * Two config entries, `preview_concurrency_new` and `preview_concurrency_all`,
+ * are available. If not set, the default values are determined with the hardware concurrency
+ * of the host. In case the hardware concurrency cannot be determined, or the user sets an
+ * invalid value, fallback values are:
+ * For new images whose previews do not exist and need to be generated, 4;
+ * For all preview generation requests, 8.
+ * Value of `preview_concurrency_all` should be greater than or equal to that of
+ * `preview_concurrency_new`, otherwise, the latter is returned.
+ *
+ * @param string $type either `preview_concurrency_new` or `preview_concurrency_all`
+ * @return int number of concurrent preview generations, or -1 if $type is invalid
+ */
+ public function getNumConcurrentPreviews(string $type): int {
+ static $cached = [];
+ if (array_key_exists($type, $cached)) {
+ return $cached[$type];
+ }
+
+ $hardwareConcurrency = self::getHardwareConcurrency();
+ switch ($type) {
+ case 'preview_concurrency_all':
+ $fallback = $hardwareConcurrency > 0 ? $hardwareConcurrency * 2 : 8;
+ $concurrency_all = $this->config->getSystemValueInt($type, $fallback);
+ $concurrency_new = $this->getNumConcurrentPreviews('preview_concurrency_new');
+ $cached[$type] = max($concurrency_all, $concurrency_new);
+ break;
+ case 'preview_concurrency_new':
+ $fallback = $hardwareConcurrency > 0 ? $hardwareConcurrency : 4;
+ $cached[$type] = $this->config->getSystemValueInt($type, $fallback);
+ break;
+ default:
+ return -1;
+ }
+ return $cached[$type];
+ }
+
+ /**
+ * @param ISimpleFolder $previewFolder
+ * @param ISimpleFile[] $previewFiles
+ * @param File $file
+ * @param string $mimeType
+ * @param string $prefix
+ * @return ISimpleFile
+ * @throws NotFoundException
+ */
+ private function getMaxPreview(ISimpleFolder $previewFolder, array $previewFiles, File $file, $mimeType, $prefix) {
+ // We don't know the max preview size, so we can't use getCachedPreview.
+ // It might have been generated with a higher resolution than the current value.
+ foreach ($previewFiles as $node) {
+ $name = $node->getName();
+ if (($prefix === '' || str_starts_with($name, $prefix)) && strpos($name, 'max')) {
+ return $node;
+ }
+ }
+
+ $maxWidth = $this->config->getSystemValueInt('preview_max_x', 4096);
+ $maxHeight = $this->config->getSystemValueInt('preview_max_y', 4096);
+
+ return $this->generateProviderPreview($previewFolder, $file, $maxWidth, $maxHeight, false, true, $mimeType, $prefix);
+ }
+
+ private function generateProviderPreview(ISimpleFolder $previewFolder, File $file, int $width, int $height, bool $crop, bool $max, string $mimeType, string $prefix) {
+ $previewProviders = $this->previewManager->getProviders();
+ foreach ($previewProviders as $supportedMimeType => $providers) {
+ // Filter out providers that does not support this mime
+ if (!preg_match($supportedMimeType, $mimeType)) {
+ continue;
+ }
+
+ foreach ($providers as $providerClosure) {
+ $provider = $this->helper->getProvider($providerClosure);
+ if (!($provider instanceof IProviderV2)) {
+ continue;
+ }
+
+ if (!$provider->isAvailable($file)) {
+ continue;
+ }
+
+ $previewConcurrency = $this->getNumConcurrentPreviews('preview_concurrency_new');
+ $sem = self::guardWithSemaphore(self::SEMAPHORE_ID_NEW, $previewConcurrency);
+ try {
+ $this->logger->debug('Calling preview provider for {mimeType} with width={width}, height={height}', [
+ 'mimeType' => $mimeType,
+ 'width' => $width,
+ 'height' => $height,
+ ]);
+ $preview = $this->helper->getThumbnail($provider, $file, $width, $height);
+ } finally {
+ self::unguardWithSemaphore($sem);
+ }
+
+ if (!($preview instanceof IImage)) {
+ continue;
+ }
+
+ $path = $this->generatePath($preview->width(), $preview->height(), $crop, $max, $preview->dataMimeType(), $prefix);
+ try {
+ if ($preview instanceof IStreamImage) {
+ return $previewFolder->newFile($path, $preview->resource());
+ } else {
+ return $previewFolder->newFile($path, $preview->data());
+ }
+ } catch (NotPermittedException $e) {
+ throw new NotFoundException();
+ }
+
+ return $file;
+ }
+ }
+
+ throw new NotFoundException('No provider successfully handled the preview generation');
+ }
+
+ /**
+ * @param ISimpleFile $file
+ * @param string $prefix
+ * @return int[]
+ */
+ private function getPreviewSize(ISimpleFile $file, string $prefix = '') {
+ $size = explode('-', substr($file->getName(), strlen($prefix)));
+ return [(int)$size[0], (int)$size[1]];
+ }
+
+ /**
+ * @param int $width
+ * @param int $height
+ * @param bool $crop
+ * @param bool $max
+ * @param string $mimeType
+ * @param string $prefix
+ * @return string
+ */
+ private function generatePath($width, $height, $crop, $max, $mimeType, $prefix) {
+ $path = $prefix . (string)$width . '-' . (string)$height;
+ if ($crop) {
+ $path .= '-crop';
+ }
+ if ($max) {
+ $path .= '-max';
+ }
+
+ $ext = $this->getExtension($mimeType);
+ $path .= '.' . $ext;
+ return $path;
+ }
+
+
+ /**
+ * @param int $width
+ * @param int $height
+ * @param bool $crop
+ * @param string $mode
+ * @param int $maxWidth
+ * @param int $maxHeight
+ * @return int[]
+ */
+ private function calculateSize($width, $height, $crop, $mode, $maxWidth, $maxHeight) {
+ /*
+ * If we are not cropping we have to make sure the requested image
+ * respects the aspect ratio of the original.
+ */
+ if (!$crop) {
+ $ratio = $maxHeight / $maxWidth;
+
+ if ($width === -1) {
+ $width = $height / $ratio;
+ }
+ if ($height === -1) {
+ $height = $width * $ratio;
+ }
+
+ $ratioH = $height / $maxHeight;
+ $ratioW = $width / $maxWidth;
+
+ /*
+ * Fill means that the $height and $width are the max
+ * Cover means min.
+ */
+ if ($mode === IPreview::MODE_FILL) {
+ if ($ratioH > $ratioW) {
+ $height = $width * $ratio;
+ } else {
+ $width = $height / $ratio;
+ }
+ } elseif ($mode === IPreview::MODE_COVER) {
+ if ($ratioH > $ratioW) {
+ $width = $height / $ratio;
+ } else {
+ $height = $width * $ratio;
+ }
+ }
+ }
+
+ if ($height !== $maxHeight && $width !== $maxWidth) {
+ /*
+ * Scale to the nearest power of four
+ */
+ $pow4height = 4 ** ceil(log($height) / log(4));
+ $pow4width = 4 ** ceil(log($width) / log(4));
+
+ // Minimum size is 64
+ $pow4height = max($pow4height, 64);
+ $pow4width = max($pow4width, 64);
+
+ $ratioH = $height / $pow4height;
+ $ratioW = $width / $pow4width;
+
+ if ($ratioH < $ratioW) {
+ $width = $pow4width;
+ $height /= $ratioW;
+ } else {
+ $height = $pow4height;
+ $width /= $ratioH;
+ }
+ }
+
+ /*
+ * Make sure the requested height and width fall within the max
+ * of the preview.
+ */
+ if ($height > $maxHeight) {
+ $ratio = $height / $maxHeight;
+ $height = $maxHeight;
+ $width /= $ratio;
+ }
+ if ($width > $maxWidth) {
+ $ratio = $width / $maxWidth;
+ $width = $maxWidth;
+ $height /= $ratio;
+ }
+
+ return [(int)round($width), (int)round($height)];
+ }
+
+ /**
+ * @throws NotFoundException
+ * @throws \InvalidArgumentException if the preview would be invalid (in case the original image is invalid)
+ */
+ private function generatePreview(
+ ISimpleFolder $previewFolder,
+ IImage $maxPreview,
+ int $width,
+ int $height,
+ bool $crop,
+ int $maxWidth,
+ int $maxHeight,
+ string $prefix,
+ bool $cacheResult,
+ ): ISimpleFile {
+ $preview = $maxPreview;
+ if (!$preview->valid()) {
+ throw new \InvalidArgumentException('Failed to generate preview, failed to load image');
+ }
+
+ $previewConcurrency = $this->getNumConcurrentPreviews('preview_concurrency_new');
+ $sem = self::guardWithSemaphore(self::SEMAPHORE_ID_NEW, $previewConcurrency);
+ try {
+ if ($crop) {
+ if ($height !== $preview->height() && $width !== $preview->width()) {
+ //Resize
+ $widthR = $preview->width() / $width;
+ $heightR = $preview->height() / $height;
+
+ if ($widthR > $heightR) {
+ $scaleH = $height;
+ $scaleW = $maxWidth / $heightR;
+ } else {
+ $scaleH = $maxHeight / $widthR;
+ $scaleW = $width;
+ }
+ $preview = $preview->preciseResizeCopy((int)round($scaleW), (int)round($scaleH));
+ }
+ $cropX = (int)floor(abs($width - $preview->width()) * 0.5);
+ $cropY = (int)floor(abs($height - $preview->height()) * 0.5);
+ $preview = $preview->cropCopy($cropX, $cropY, $width, $height);
+ } else {
+ $preview = $maxPreview->resizeCopy(max($width, $height));
+ }
+ } finally {
+ self::unguardWithSemaphore($sem);
+ }
+
+
+ $path = $this->generatePath($width, $height, $crop, false, $preview->dataMimeType(), $prefix);
+ try {
+ if ($cacheResult) {
+ return $previewFolder->newFile($path, $preview->data());
+ } else {
+ return new InMemoryFile($path, $preview->data());
+ }
+ } catch (NotPermittedException $e) {
+ throw new NotFoundException();
+ }
+ return $file;
+ }
+
+ /**
+ * @param ISimpleFile[] $files Array of FileInfo, as the result of getDirectoryListing()
+ * @param int $width
+ * @param int $height
+ * @param bool $crop
+ * @param string $mimeType
+ * @param string $prefix
+ * @return ISimpleFile
+ *
+ * @throws NotFoundException
+ */
+ private function getCachedPreview($files, $width, $height, $crop, $mimeType, $prefix) {
+ $path = $this->generatePath($width, $height, $crop, false, $mimeType, $prefix);
+ foreach ($files as $file) {
+ if ($file->getName() === $path) {
+ $this->logger->debug('Found cached preview: {path}', ['path' => $path]);
+ return $file;
+ }
+ }
+ throw new NotFoundException();
+ }
+
+ /**
+ * Get the specific preview folder for this file
+ *
+ * @param File $file
+ * @return ISimpleFolder
+ *
+ * @throws InvalidPathException
+ * @throws NotFoundException
+ * @throws NotPermittedException
+ */
+ private function getPreviewFolder(File $file) {
+ // Obtain file id outside of try catch block to prevent the creation of an existing folder
+ $fileId = (string)$file->getId();
+
+ try {
+ $folder = $this->appData->getFolder($fileId);
+ } catch (NotFoundException $e) {
+ $folder = $this->appData->newFolder($fileId);
+ }
+
+ return $folder;
+ }
+
+ /**
+ * @param string $mimeType
+ * @return null|string
+ * @throws \InvalidArgumentException
+ */
+ private function getExtension($mimeType) {
+ switch ($mimeType) {
+ case 'image/png':
+ return 'png';
+ case 'image/jpeg':
+ return 'jpg';
+ case 'image/webp':
+ return 'webp';
+ case 'image/gif':
+ return 'gif';
+ default:
+ throw new \InvalidArgumentException('Not a valid mimetype: "' . $mimeType . '"');
+ }
+ }
+}
diff --git a/lib/private/Preview/GeneratorHelper.php b/lib/private/Preview/GeneratorHelper.php
new file mode 100644
index 00000000000..e914dcc2002
--- /dev/null
+++ b/lib/private/Preview/GeneratorHelper.php
@@ -0,0 +1,69 @@
+<?php
+
+/**
+ * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OC\Preview;
+
+use OCP\Files\File;
+use OCP\Files\IRootFolder;
+use OCP\Files\SimpleFS\ISimpleFile;
+use OCP\IConfig;
+use OCP\IImage;
+use OCP\Image as OCPImage;
+use OCP\Preview\IProvider;
+use OCP\Preview\IProviderV2;
+
+/**
+ * Very small wrapper class to make the generator fully unit testable
+ */
+class GeneratorHelper {
+ /** @var IRootFolder */
+ private $rootFolder;
+
+ /** @var IConfig */
+ private $config;
+
+ public function __construct(IRootFolder $rootFolder, IConfig $config) {
+ $this->rootFolder = $rootFolder;
+ $this->config = $config;
+ }
+
+ /**
+ * @param IProviderV2 $provider
+ * @param File $file
+ * @param int $maxWidth
+ * @param int $maxHeight
+ *
+ * @return bool|IImage
+ */
+ public function getThumbnail(IProviderV2 $provider, File $file, $maxWidth, $maxHeight, bool $crop = false) {
+ if ($provider instanceof Imaginary) {
+ return $provider->getCroppedThumbnail($file, $maxWidth, $maxHeight, $crop) ?? false;
+ }
+ return $provider->getThumbnail($file, $maxWidth, $maxHeight) ?? false;
+ }
+
+ /**
+ * @param ISimpleFile $maxPreview
+ * @return IImage
+ */
+ public function getImage(ISimpleFile $maxPreview) {
+ $image = new OCPImage();
+ $image->loadFromData($maxPreview->getContent());
+ return $image;
+ }
+
+ /**
+ * @param callable $providerClosure
+ * @return IProviderV2
+ */
+ public function getProvider($providerClosure) {
+ $provider = $providerClosure();
+ if ($provider instanceof IProvider) {
+ $provider = new ProviderV1Adapter($provider);
+ }
+ return $provider;
+ }
+}
diff --git a/lib/private/Preview/HEIC.php b/lib/private/Preview/HEIC.php
new file mode 100644
index 00000000000..64eb48e58df
--- /dev/null
+++ b/lib/private/Preview/HEIC.php
@@ -0,0 +1,152 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-FileCopyrightText: 2018 ownCloud GmbH
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+namespace OC\Preview;
+
+use OCP\Files\File;
+use OCP\Files\FileInfo;
+use OCP\IImage;
+use OCP\Server;
+use Psr\Log\LoggerInterface;
+
+/**
+ * Creates a JPG preview using ImageMagick via the PECL extension
+ *
+ * @package OC\Preview
+ */
+class HEIC extends ProviderV2 {
+ /**
+ * {@inheritDoc}
+ */
+ public function getMimeType(): string {
+ return '/image\/(x-)?hei(f|c)/';
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function isAvailable(FileInfo $file): bool {
+ return in_array('HEIC', \Imagick::queryFormats('HEI*'));
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function getThumbnail(File $file, int $maxX, int $maxY): ?IImage {
+ if (!$this->isAvailable($file)) {
+ return null;
+ }
+
+ $tmpPath = $this->getLocalFile($file);
+ if ($tmpPath === false) {
+ Server::get(LoggerInterface::class)->error(
+ 'Failed to get local file to generate thumbnail for: ' . $file->getPath(),
+ ['app' => 'core']
+ );
+ return null;
+ }
+
+ // Creates \Imagick object from the heic file
+ try {
+ $bp = $this->getResizedPreview($tmpPath, $maxX, $maxY);
+ $bp->setFormat('jpg');
+ } catch (\Exception $e) {
+ \OC::$server->get(LoggerInterface::class)->error(
+ 'File: ' . $file->getPath() . ' Imagick says:',
+ [
+ 'exception' => $e,
+ 'app' => 'core',
+ ]
+ );
+ return null;
+ }
+
+ $this->cleanTmpFiles();
+
+ //new bitmap image object
+ $image = new \OCP\Image();
+ $image->loadFromData((string)$bp);
+ //check if image object is valid
+ return $image->valid() ? $image : null;
+ }
+
+ /**
+ * Returns a preview of maxX times maxY dimensions in JPG format
+ *
+ * * The default resolution is already 72dpi, no need to change it for a bitmap output
+ * * It's possible to have proper colour conversion using profileimage().
+ * ICC profiles are here: http://www.color.org/srgbprofiles.xalter
+ * * It's possible to Gamma-correct an image via gammaImage()
+ *
+ * @param string $tmpPath the location of the file to convert
+ * @param int $maxX
+ * @param int $maxY
+ *
+ * @return \Imagick
+ *
+ * @throws \Exception
+ */
+ private function getResizedPreview($tmpPath, $maxX, $maxY) {
+ $bp = new \Imagick();
+
+ // Some HEIC files just contain (or at least are identified as) other formats
+ // like JPEG. We just need to check if the image is safe to process.
+ $bp->pingImage($tmpPath . '[0]');
+ $mimeType = $bp->getImageMimeType();
+ if (!preg_match('/^image\/(x-)?(png|jpeg|gif|bmp|tiff|webp|hei(f|c)|avif)$/', $mimeType)) {
+ throw new \Exception('File mime type does not match the preview provider: ' . $mimeType);
+ }
+
+ // Layer 0 contains either the bitmap or a flat representation of all vector layers
+ $bp->readImage($tmpPath . '[0]');
+
+ // Fix orientation from EXIF
+ $bp->autoOrient();
+
+ $bp->setImageFormat('jpg');
+
+ $bp = $this->resize($bp, $maxX, $maxY);
+
+ return $bp;
+ }
+
+ /**
+ * Returns a resized \Imagick object
+ *
+ * If you want to know more on the various methods available to resize an
+ * image, check out this link : @link https://stackoverflow.com/questions/8517304/what-the-difference-of-sample-resample-scale-resize-adaptive-resize-thumbnail-im
+ *
+ * @param \Imagick $bp
+ * @param int $maxX
+ * @param int $maxY
+ *
+ * @return \Imagick
+ */
+ private function resize($bp, $maxX, $maxY) {
+ [$previewWidth, $previewHeight] = array_values($bp->getImageGeometry());
+
+ // We only need to resize a preview which doesn't fit in the maximum dimensions
+ if ($previewWidth > $maxX || $previewHeight > $maxY) {
+ // If we want a small image (thumbnail) let's be most space- and time-efficient
+ if ($maxX <= 500 && $maxY <= 500) {
+ $bp->thumbnailImage($maxY, $maxX, true);
+ $bp->stripImage();
+ } else {
+ // A bigger image calls for some better resizing algorithm
+ // According to http://www.imagemagick.org/Usage/filter/#lanczos
+ // the catrom filter is almost identical to Lanczos2, but according
+ // to https://www.php.net/manual/en/imagick.resizeimage.php it is
+ // significantly faster
+ $bp->resizeImage($maxX, $maxY, \Imagick::FILTER_CATROM, 1, true);
+ }
+ }
+
+ return $bp;
+ }
+}
diff --git a/lib/private/Preview/IMagickSupport.php b/lib/private/Preview/IMagickSupport.php
new file mode 100644
index 00000000000..8225b1e7644
--- /dev/null
+++ b/lib/private/Preview/IMagickSupport.php
@@ -0,0 +1,44 @@
+<?php
+
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OC\Preview;
+
+use OCP\ICache;
+use OCP\ICacheFactory;
+
+class IMagickSupport {
+ private ICache $cache;
+ private ?\Imagick $imagick;
+
+ public function __construct(ICacheFactory $cacheFactory) {
+ $this->cache = $cacheFactory->createLocal('imagick');
+
+ if (extension_loaded('imagick')) {
+ $this->imagick = new \Imagick();
+ } else {
+ $this->imagick = null;
+ }
+ }
+
+ public function hasExtension(): bool {
+ return !is_null($this->imagick);
+ }
+
+ public function supportsFormat(string $format): bool {
+ if (is_null($this->imagick)) {
+ return false;
+ }
+
+ $cached = $this->cache->get($format);
+ if (!is_null($cached)) {
+ return $cached;
+ }
+
+ $formatSupported = count($this->imagick->queryFormats($format)) === 1;
+ $this->cache->set($format, $cached);
+ return $formatSupported;
+ }
+}
diff --git a/lib/private/Preview/Illustrator.php b/lib/private/Preview/Illustrator.php
new file mode 100644
index 00000000000..bff556a3177
--- /dev/null
+++ b/lib/private/Preview/Illustrator.php
@@ -0,0 +1,25 @@
+<?php
+
+/**
+ * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+namespace OC\Preview;
+
+//.ai
+class Illustrator extends Bitmap {
+ /**
+ * {@inheritDoc}
+ */
+ public function getMimeType(): string {
+ return '/application\/illustrator/';
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ protected function getAllowedMimeTypes(): string {
+ return '/application\/(illustrator|pdf)/';
+ }
+}
diff --git a/lib/private/Preview/Image.php b/lib/private/Preview/Image.php
new file mode 100644
index 00000000000..78a402c636a
--- /dev/null
+++ b/lib/private/Preview/Image.php
@@ -0,0 +1,50 @@
+<?php
+
+/**
+ * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+namespace OC\Preview;
+
+use OCP\Files\File;
+use OCP\IImage;
+use OCP\Server;
+use Psr\Log\LoggerInterface;
+
+abstract class Image extends ProviderV2 {
+ /**
+ * {@inheritDoc}
+ */
+ public function getThumbnail(File $file, int $maxX, int $maxY): ?IImage {
+ $maxSizeForImages = \OC::$server->getConfig()->getSystemValueInt('preview_max_filesize_image', 50);
+ $size = $file->getSize();
+
+ if ($maxSizeForImages !== -1 && $size > ($maxSizeForImages * 1024 * 1024)) {
+ return null;
+ }
+
+ $image = new \OCP\Image();
+
+ $fileName = $this->getLocalFile($file);
+ if ($fileName === false) {
+ Server::get(LoggerInterface::class)->error(
+ 'Failed to get local file to generate thumbnail for: ' . $file->getPath(),
+ ['app' => 'core']
+ );
+ return null;
+ }
+
+ $image->loadFromFile($fileName);
+ $image->fixOrientation();
+
+ $this->cleanTmpFiles();
+
+ if ($image->valid()) {
+ $image->scaleDownToFit($maxX, $maxY);
+
+ return $image;
+ }
+ return null;
+ }
+}
diff --git a/lib/private/Preview/Imaginary.php b/lib/private/Preview/Imaginary.php
new file mode 100644
index 00000000000..d421da74ac8
--- /dev/null
+++ b/lib/private/Preview/Imaginary.php
@@ -0,0 +1,191 @@
+<?php
+
+/**
+ * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OC\Preview;
+
+use OC\StreamImage;
+use OCP\Files\File;
+use OCP\Http\Client\IClientService;
+use OCP\IConfig;
+use OCP\IImage;
+
+use OCP\Image;
+use Psr\Log\LoggerInterface;
+
+class Imaginary extends ProviderV2 {
+ /** @var IConfig */
+ private $config;
+
+ /** @var IClientService */
+ private $service;
+
+ /** @var LoggerInterface */
+ private $logger;
+
+ public function __construct(array $config) {
+ parent::__construct($config);
+ $this->config = \OC::$server->get(IConfig::class);
+ $this->service = \OC::$server->get(IClientService::class);
+ $this->logger = \OC::$server->get(LoggerInterface::class);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function getMimeType(): string {
+ return self::supportedMimeTypes();
+ }
+
+ public static function supportedMimeTypes(): string {
+ return '/(image\/(bmp|x-bitmap|png|jpeg|gif|heic|heif|svg\+xml|tiff|webp)|application\/illustrator)/';
+ }
+
+ public function getCroppedThumbnail(File $file, int $maxX, int $maxY, bool $crop): ?IImage {
+ $maxSizeForImages = $this->config->getSystemValueInt('preview_max_filesize_image', 50);
+
+ $size = $file->getSize();
+
+ if ($maxSizeForImages !== -1 && $size > ($maxSizeForImages * 1024 * 1024)) {
+ return null;
+ }
+
+ $imaginaryUrl = $this->config->getSystemValueString('preview_imaginary_url', 'invalid');
+ if ($imaginaryUrl === 'invalid') {
+ $this->logger->error('Imaginary preview provider is enabled, but no url is configured. Please provide the url of your imaginary server to the \'preview_imaginary_url\' config variable.');
+ return null;
+ }
+ $imaginaryUrl = rtrim($imaginaryUrl, '/');
+
+ // Object store
+ $stream = $file->fopen('r');
+ if (!$stream || !is_resource($stream) || feof($stream)) {
+ return null;
+ }
+
+ $httpClient = $this->service->newClient();
+
+ $convert = false;
+ $autorotate = true;
+
+ switch ($file->getMimeType()) {
+ case 'image/heic':
+ // Autorotate seems to be broken for Heic so disable for that
+ $autorotate = false;
+ $mimeType = 'jpeg';
+ break;
+ case 'image/gif':
+ case 'image/png':
+ $mimeType = 'png';
+ break;
+ case 'image/svg+xml':
+ case 'application/pdf':
+ case 'application/illustrator':
+ $convert = true;
+ // Converted files do not need to be autorotated
+ $autorotate = false;
+ $mimeType = 'png';
+ break;
+ default:
+ $mimeType = 'jpeg';
+ }
+
+ $preview_format = $this->config->getSystemValueString('preview_format', 'jpeg');
+
+ switch ($preview_format) { // Change the format to the correct one
+ case 'webp':
+ $mimeType = 'webp';
+ break;
+ default:
+ }
+
+ $operations = [];
+
+ if ($convert) {
+ $operations[] = [
+ 'operation' => 'convert',
+ 'params' => [
+ 'type' => $mimeType,
+ ]
+ ];
+ } elseif ($autorotate) {
+ $operations[] = [
+ 'operation' => 'autorotate',
+ ];
+ }
+
+ switch ($mimeType) {
+ case 'jpeg':
+ $quality = $this->config->getAppValue('preview', 'jpeg_quality', '80');
+ break;
+ case 'webp':
+ $quality = $this->config->getAppValue('preview', 'webp_quality', '80');
+ break;
+ default:
+ $quality = $this->config->getAppValue('preview', 'jpeg_quality', '80');
+ }
+
+ $operations[] = [
+ 'operation' => ($crop ? 'smartcrop' : 'fit'),
+ 'params' => [
+ 'width' => $maxX,
+ 'height' => $maxY,
+ 'stripmeta' => 'true',
+ 'type' => $mimeType,
+ 'norotation' => 'true',
+ 'quality' => $quality,
+ ]
+ ];
+
+ try {
+ $imaginaryKey = $this->config->getSystemValueString('preview_imaginary_key', '');
+ $response = $httpClient->post(
+ $imaginaryUrl . '/pipeline', [
+ 'query' => ['operations' => json_encode($operations), 'key' => $imaginaryKey],
+ 'stream' => true,
+ 'content-type' => $file->getMimeType(),
+ 'body' => $stream,
+ 'nextcloud' => ['allow_local_address' => true],
+ 'timeout' => 120,
+ 'connect_timeout' => 3,
+ ]);
+ } catch (\Throwable $e) {
+ $this->logger->info('Imaginary preview generation failed: ' . $e->getMessage(), [
+ 'exception' => $e,
+ ]);
+ return null;
+ }
+
+ if ($response->getStatusCode() !== 200) {
+ $this->logger->info('Imaginary preview generation failed: ' . json_decode($response->getBody())['message']);
+ return null;
+ }
+
+ // This is not optimal but previews are distorted if the wrong width and height values are
+ // used. Both dimension headers are only sent when passing the option "-return-size" to
+ // Imaginary.
+ if ($response->getHeader('Image-Width') && $response->getHeader('Image-Height')) {
+ $image = new StreamImage(
+ $response->getBody(),
+ $response->getHeader('Content-Type'),
+ (int)$response->getHeader('Image-Width'),
+ (int)$response->getHeader('Image-Height'),
+ );
+ } else {
+ $image = new Image();
+ $image->loadFromFileHandle($response->getBody());
+ }
+
+ return $image->valid() ? $image : null;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function getThumbnail(File $file, int $maxX, int $maxY): ?IImage {
+ return $this->getCroppedThumbnail($file, $maxX, $maxY, false);
+ }
+}
diff --git a/lib/private/Preview/ImaginaryPDF.php b/lib/private/Preview/ImaginaryPDF.php
new file mode 100644
index 00000000000..d26c6d1b7ff
--- /dev/null
+++ b/lib/private/Preview/ImaginaryPDF.php
@@ -0,0 +1,14 @@
+<?php
+
+/**
+ * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OC\Preview;
+
+class ImaginaryPDF extends Imaginary {
+ public static function supportedMimeTypes(): string {
+ return '/application\/pdf/';
+ }
+}
diff --git a/lib/private/Preview/JPEG.php b/lib/private/Preview/JPEG.php
new file mode 100644
index 00000000000..a1a394f81c9
--- /dev/null
+++ b/lib/private/Preview/JPEG.php
@@ -0,0 +1,17 @@
+<?php
+
+/**
+ * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+namespace OC\Preview;
+
+class JPEG extends Image {
+ /**
+ * {@inheritDoc}
+ */
+ public function getMimeType(): string {
+ return '/image\/jpeg/';
+ }
+}
diff --git a/lib/private/Preview/Krita.php b/lib/private/Preview/Krita.php
new file mode 100644
index 00000000000..e96fac993aa
--- /dev/null
+++ b/lib/private/Preview/Krita.php
@@ -0,0 +1,35 @@
+<?php
+
+/**
+ * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OC\Preview;
+
+use OCP\Files\File;
+use OCP\IImage;
+
+class Krita extends Bundled {
+ /**
+ * {@inheritDoc}
+ */
+ public function getMimeType(): string {
+ return '/application\/x-krita/';
+ }
+
+
+ /**
+ * @inheritDoc
+ */
+ public function getThumbnail(File $file, int $maxX, int $maxY): ?IImage {
+ $image = $this->extractThumbnail($file, 'mergedimage.png');
+ if (($image !== null) && $image->valid()) {
+ return $image;
+ }
+ $image = $this->extractThumbnail($file, 'preview.png');
+ if (($image !== null) && $image->valid()) {
+ return $image;
+ }
+ return null;
+ }
+}
diff --git a/lib/private/Preview/MP3.php b/lib/private/Preview/MP3.php
new file mode 100644
index 00000000000..add0028738e
--- /dev/null
+++ b/lib/private/Preview/MP3.php
@@ -0,0 +1,65 @@
+<?php
+
+/**
+ * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+namespace OC\Preview;
+
+use OCP\Files\File;
+use OCP\IImage;
+use OCP\Server;
+use Psr\Log\LoggerInterface;
+use wapmorgan\Mp3Info\Mp3Info;
+use function OCP\Log\logger;
+
+class MP3 extends ProviderV2 {
+ /**
+ * {@inheritDoc}
+ */
+ public function getMimeType(): string {
+ return '/audio\/mpeg/';
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function getThumbnail(File $file, int $maxX, int $maxY): ?IImage {
+ $tmpPath = $this->getLocalFile($file);
+ if ($tmpPath === false) {
+ Server::get(LoggerInterface::class)->error(
+ 'Failed to get local file to generate thumbnail for: ' . $file->getPath(),
+ ['app' => 'core']
+ );
+ return null;
+ }
+
+ try {
+ $audio = new Mp3Info($tmpPath, true);
+ /** @var string|null|false $picture */
+ $picture = $audio->getCover();
+ } catch (\Throwable $e) {
+ logger('core')->info('Error while getting cover from mp3 file: ' . $e->getMessage(), [
+ 'fileId' => $file->getId(),
+ 'filePath' => $file->getPath(),
+ ]);
+ return null;
+ } finally {
+ $this->cleanTmpFiles();
+ }
+
+ if (is_string($picture)) {
+ $image = new \OCP\Image();
+ $image->loadFromData($picture);
+
+ if ($image->valid()) {
+ $image->scaleDownToFit($maxX, $maxY);
+
+ return $image;
+ }
+ }
+
+ return null;
+ }
+}
diff --git a/lib/private/Preview/MSOffice2003.php b/lib/private/Preview/MSOffice2003.php
new file mode 100644
index 00000000000..a52e618d484
--- /dev/null
+++ b/lib/private/Preview/MSOffice2003.php
@@ -0,0 +1,18 @@
+<?php
+
+/**
+ * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+namespace OC\Preview;
+
+//.docm, .dotm, .xls(m), .xlt(m), .xla(m), .ppt(m), .pot(m), .pps(m), .ppa(m)
+class MSOffice2003 extends Office {
+ /**
+ * {@inheritDoc}
+ */
+ public function getMimeType(): string {
+ return '/application\/vnd.ms-.*/';
+ }
+}
diff --git a/lib/private/Preview/MSOffice2007.php b/lib/private/Preview/MSOffice2007.php
new file mode 100644
index 00000000000..317f2dcc7f1
--- /dev/null
+++ b/lib/private/Preview/MSOffice2007.php
@@ -0,0 +1,18 @@
+<?php
+
+/**
+ * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+namespace OC\Preview;
+
+//.docx, .dotx, .xlsx, .xltx, .pptx, .potx, .ppsx
+class MSOffice2007 extends Office {
+ /**
+ * {@inheritDoc}
+ */
+ public function getMimeType(): string {
+ return '/application\/vnd.openxmlformats-officedocument.*/';
+ }
+}
diff --git a/lib/private/Preview/MSOfficeDoc.php b/lib/private/Preview/MSOfficeDoc.php
new file mode 100644
index 00000000000..2e1044395f1
--- /dev/null
+++ b/lib/private/Preview/MSOfficeDoc.php
@@ -0,0 +1,18 @@
+<?php
+
+/**
+ * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+namespace OC\Preview;
+
+//.doc, .dot
+class MSOfficeDoc extends Office {
+ /**
+ * {@inheritDoc}
+ */
+ public function getMimeType(): string {
+ return '/application\/msword/';
+ }
+}
diff --git a/lib/private/Preview/MarkDown.php b/lib/private/Preview/MarkDown.php
new file mode 100644
index 00000000000..c20433a1ac0
--- /dev/null
+++ b/lib/private/Preview/MarkDown.php
@@ -0,0 +1,127 @@
+<?php
+
+/**
+ * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+namespace OC\Preview;
+
+use OCP\Files\File;
+use OCP\IImage;
+
+class MarkDown extends TXT {
+ /**
+ * {@inheritDoc}
+ */
+ public function getMimeType(): string {
+ return '/text\/(x-)?markdown/';
+ }
+
+ public function getThumbnail(File $file, int $maxX, int $maxY): ?IImage {
+ $content = $file->fopen('r');
+
+ if ($content === false) {
+ return null;
+ }
+
+ $content = stream_get_contents($content, 3000);
+
+ //don't create previews of empty text files
+ if (trim($content) === '') {
+ return null;
+ }
+
+ // Merge text paragraph lines that might belong together
+ $content = preg_replace('/^(\s*)\*\s/mU', '$1- ', $content);
+
+ $content = preg_replace('/((?!^(\s*-|#)).*)(\w|\\|\.)(\r\n|\n|\r)(\w|\*)/mU', '$1 $3', $content);
+
+ // Remove markdown symbols that we cannot easily represent in rendered text in the preview
+ $content = preg_replace('/\*\*(.*)\*\*/U', '$1', $content);
+ $content = preg_replace('/\*(.*)\*/U', '$1', $content);
+ $content = preg_replace('/\_\_(.*)\_\_/U', '$1', $content);
+ $content = preg_replace('/\_(.*)\_/U', '$1', $content);
+ $content = preg_replace('/\~\~(.*)\~\~/U', '$1', $content);
+
+ $content = preg_replace('/\!?\[((.|\n)*)\]\((.*)\)/mU', '$1 ($3)', $content);
+ $content = preg_replace('/\n\n+/', "\n", $content);
+
+ $content = preg_replace('/[\x{10000}-\x{10FFFF}]/u', '', $content);
+
+ $lines = preg_split("/\r\n|\n|\r/", $content);
+
+ // Define text size of text file preview
+ $fontSize = $maxX ? (int)((1 / ($maxX >= 512 ? 60 : 40) * $maxX)) : 10;
+
+ $image = imagecreate($maxX, $maxY);
+ imagecolorallocate($image, 255, 255, 255);
+ $textColor = imagecolorallocate($image, 0, 0, 0);
+
+ $fontFile = __DIR__ . '/../../../core/fonts/NotoSans-Regular.ttf';
+ $fontFileBold = __DIR__ . '/../../../core/fonts/NotoSans-Bold.ttf';
+
+ $canUseTTF = function_exists('imagettftext');
+
+ $textOffset = (int)min($maxX * 0.05, $maxY * 0.05);
+ $nextLineStart = 0;
+ $y = $textOffset;
+ foreach ($lines as $line) {
+ $actualFontSize = $fontSize;
+ if (mb_strpos($line, '# ') === 0) {
+ $actualFontSize *= 2;
+ }
+ if (mb_strpos($line, '## ') === 0) {
+ $actualFontSize *= 1.8;
+ }
+ if (mb_strpos($line, '### ') === 0) {
+ $actualFontSize *= 1.6;
+ }
+ if (mb_strpos($line, '#### ') === 0) {
+ $actualFontSize *= 1.4;
+ }
+ if (mb_strpos($line, '##### ') === 0) {
+ $actualFontSize *= 1.2;
+ }
+ if (mb_strpos($line, '###### ') === 0) {
+ $actualFontSize *= 1.1;
+ }
+
+ // Add spacing before headlines
+ if ($actualFontSize !== $fontSize && $y !== $textOffset) {
+ $y += (int)($actualFontSize * 2);
+ }
+
+ $x = $textOffset;
+ $y += (int)($nextLineStart + $actualFontSize);
+
+ if ($canUseTTF === true) {
+ $wordWrap = (int)((1 / $actualFontSize * 1.3) * $maxX);
+
+ // Get rid of markdown symbols that we still needed for the font size
+ $line = preg_replace('/^#*\s/', '', $line);
+
+ $wrappedText = wordwrap($line, $wordWrap, "\n");
+ $linesWrapped = count(explode("\n", $wrappedText));
+ imagettftext($image, $actualFontSize, 0, $x, $y, $textColor, $actualFontSize === $fontSize ? $fontFile : $fontFileBold, $wrappedText);
+ $nextLineStart = (int)($linesWrapped * ceil($actualFontSize * 2));
+ if ($actualFontSize !== $fontSize && $y !== $textOffset) {
+ $nextLineStart -= $actualFontSize;
+ }
+ } else {
+ $y -= $fontSize;
+ imagestring($image, 1, $x, $y, $line, $textColor);
+ $nextLineStart = $fontSize;
+ }
+
+ if ($y >= $maxY) {
+ break;
+ }
+ }
+
+ $imageObject = new \OCP\Image();
+ $imageObject->setResource($image);
+
+ return $imageObject->valid() ? $imageObject : null;
+ }
+}
diff --git a/lib/private/Preview/MimeIconProvider.php b/lib/private/Preview/MimeIconProvider.php
new file mode 100644
index 00000000000..d1963fe882b
--- /dev/null
+++ b/lib/private/Preview/MimeIconProvider.php
@@ -0,0 +1,83 @@
+<?php
+
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OC\Preview;
+
+use OCA\Theming\ThemingDefaults;
+use OCP\App\IAppManager;
+use OCP\Files\IMimeTypeDetector;
+use OCP\IConfig;
+use OCP\IURLGenerator;
+use OCP\Preview\IMimeIconProvider;
+
+class MimeIconProvider implements IMimeIconProvider {
+ public function __construct(
+ protected IMimeTypeDetector $mimetypeDetector,
+ protected IConfig $config,
+ protected IURLGenerator $urlGenerator,
+ protected IAppManager $appManager,
+ protected ThemingDefaults $themingDefaults,
+ ) {
+ }
+
+ public function getMimeIconUrl(string $mime): ?string {
+ if (!$mime) {
+ return null;
+ }
+
+ // Fetch all the aliases
+ $aliases = $this->mimetypeDetector->getAllAliases();
+
+ // Remove comments
+ $aliases = array_filter($aliases, static function (string $key) {
+ return !($key === '' || $key[0] === '_');
+ }, ARRAY_FILTER_USE_KEY);
+
+ // Map all the aliases recursively
+ foreach ($aliases as $alias => $value) {
+ if ($alias === $mime) {
+ $mime = $value;
+ }
+ }
+
+ $fileName = str_replace('/', '-', $mime);
+ if ($url = $this->searchfileName($fileName)) {
+ return $url;
+ }
+
+ $mimeType = explode('/', $mime)[0];
+ if ($url = $this->searchfileName($mimeType)) {
+ return $url;
+ }
+
+ return null;
+ }
+
+ private function searchfileName(string $fileName): ?string {
+ // If the file exists in the current enabled legacy
+ // custom theme, let's return it
+ $theme = $this->config->getSystemValue('theme', '');
+ if (!empty($theme)) {
+ $path = "/themes/$theme/core/img/filetypes/$fileName.svg";
+ if (file_exists(\OC::$SERVERROOT . $path)) {
+ return $this->urlGenerator->getAbsoluteURL($path);
+ }
+ }
+
+ // Previously, we used to pass this through Theming
+ // But it was only used to colour icons containing
+ // 0082c9. Since with vue we moved to inline svg icons,
+ // we can just use the default core icons.
+
+ // Finally, if the file exists in core, let's return it
+ $path = "/core/img/filetypes/$fileName.svg";
+ if (file_exists(\OC::$SERVERROOT . $path)) {
+ return $this->urlGenerator->getAbsoluteURL($path);
+ }
+
+ return null;
+ }
+}
diff --git a/lib/private/Preview/Movie.php b/lib/private/Preview/Movie.php
new file mode 100644
index 00000000000..47895f999d8
--- /dev/null
+++ b/lib/private/Preview/Movie.php
@@ -0,0 +1,194 @@
+<?php
+
+/**
+ * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+namespace OC\Preview;
+
+use OCP\Files\File;
+use OCP\Files\FileInfo;
+use OCP\IConfig;
+use OCP\IImage;
+use OCP\ITempManager;
+use OCP\Server;
+use Psr\Log\LoggerInterface;
+
+class Movie extends ProviderV2 {
+ private IConfig $config;
+
+ private ?string $binary = null;
+
+ public function __construct(array $options = []) {
+ parent::__construct($options);
+ $this->config = Server::get(IConfig::class);
+ }
+
+ public function getMimeType(): string {
+ return '/video\/.*/';
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function isAvailable(FileInfo $file): bool {
+ if (is_null($this->binary)) {
+ if (isset($this->options['movieBinary'])) {
+ $this->binary = $this->options['movieBinary'];
+ }
+ }
+ return is_string($this->binary);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function getThumbnail(File $file, int $maxX, int $maxY): ?IImage {
+ // TODO: use proc_open() and stream the source file ?
+
+ if (!$this->isAvailable($file)) {
+ return null;
+ }
+
+ $result = null;
+ if ($this->useTempFile($file)) {
+ // Try downloading 5 MB first, as it's likely that the first frames are present there.
+ // In some cases this doesn't work, for example when the moov atom is at the
+ // end of the file, so if it fails we fall back to getting the full file.
+ // Unless the file is not local (e.g. S3) as we do not want to download the whole (e.g. 37Gb) file
+ if ($file->getStorage()->isLocal()) {
+ $sizeAttempts = [5242880, null];
+ } else {
+ $sizeAttempts = [5242880];
+ }
+ } else {
+ // size is irrelevant, only attempt once
+ $sizeAttempts = [null];
+ }
+
+ foreach ($sizeAttempts as $size) {
+ $absPath = $this->getLocalFile($file, $size);
+ if ($absPath === false) {
+ Server::get(LoggerInterface::class)->error(
+ 'Failed to get local file to generate thumbnail for: ' . $file->getPath(),
+ ['app' => 'core']
+ );
+ return null;
+ }
+
+ $result = $this->generateThumbNail($maxX, $maxY, $absPath, 5);
+ if ($result === null) {
+ $result = $this->generateThumbNail($maxX, $maxY, $absPath, 1);
+ if ($result === null) {
+ $result = $this->generateThumbNail($maxX, $maxY, $absPath, 0);
+ }
+ }
+
+ $this->cleanTmpFiles();
+
+ if ($result !== null) {
+ break;
+ }
+ }
+
+ return $result;
+ }
+
+ private function useHdr(string $absPath): bool {
+ // load ffprobe path from configuration, otherwise generate binary path using ffmpeg binary path
+ $ffprobe_binary = $this->config->getSystemValue('preview_ffprobe_path', null) ?? (pathinfo($this->binary, PATHINFO_DIRNAME) . '/ffprobe');
+ // run ffprobe on the video file to get value of "color_transfer"
+ $test_hdr_cmd = [$ffprobe_binary,'-select_streams', 'v:0',
+ '-show_entries', 'stream=color_transfer',
+ '-of', 'default=noprint_wrappers=1:nokey=1',
+ $absPath];
+ $test_hdr_proc = proc_open($test_hdr_cmd, [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $test_hdr_pipes);
+ if ($test_hdr_proc === false) {
+ return false;
+ }
+ $test_hdr_stdout = trim(stream_get_contents($test_hdr_pipes[1]));
+ $test_hdr_stderr = trim(stream_get_contents($test_hdr_pipes[2]));
+ proc_close($test_hdr_proc);
+ // search build options for libzimg (provides zscale filter)
+ $ffmpeg_libzimg_installed = strpos($test_hdr_stderr, '--enable-libzimg');
+ // Only values of "smpte2084" and "arib-std-b67" indicate an HDR video.
+ // Only return true if video is detected as HDR and libzimg is installed.
+ if (($test_hdr_stdout === 'smpte2084' || $test_hdr_stdout === 'arib-std-b67') && $ffmpeg_libzimg_installed !== false) {
+ return true;
+ } else {
+ return false;
+ }
+ }
+
+ private function generateThumbNail(int $maxX, int $maxY, string $absPath, int $second): ?IImage {
+ $tmpPath = Server::get(ITempManager::class)->getTemporaryFile();
+
+ if ($tmpPath === false) {
+ Server::get(LoggerInterface::class)->error(
+ 'Failed to get local file to generate thumbnail for: ' . $absPath,
+ ['app' => 'core']
+ );
+ return null;
+ }
+
+ $binaryType = substr(strrchr($this->binary, '/'), 1);
+
+ if ($binaryType === 'avconv') {
+ $cmd = [$this->binary, '-y', '-ss', (string)$second,
+ '-i', $absPath,
+ '-an', '-f', 'mjpeg', '-vframes', '1', '-vsync', '1',
+ $tmpPath];
+ } elseif ($binaryType === 'ffmpeg') {
+ if ($this->useHdr($absPath)) {
+ // Force colorspace to '2020_ncl' because some videos are
+ // tagged incorrectly as 'reserved' resulting in fail if not forced.
+ $cmd = [$this->binary, '-y', '-ss', (string)$second,
+ '-i', $absPath,
+ '-f', 'mjpeg', '-vframes', '1',
+ '-vf', 'zscale=min=2020_ncl:t=linear:npl=100,format=gbrpf32le,zscale=p=bt709,tonemap=tonemap=hable:desat=0,zscale=t=bt709:m=bt709:r=tv,format=yuv420p',
+ $tmpPath];
+ } else {
+ // always default to generating preview using non-HDR command
+ $cmd = [$this->binary, '-y', '-ss', (string)$second,
+ '-i', $absPath,
+ '-f', 'mjpeg', '-vframes', '1',
+ $tmpPath];
+ }
+ } else {
+ // Not supported
+ unlink($tmpPath);
+ return null;
+ }
+
+ $proc = proc_open($cmd, [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes);
+ $returnCode = -1;
+ $output = '';
+ if (is_resource($proc)) {
+ $stderr = trim(stream_get_contents($pipes[2]));
+ $stdout = trim(stream_get_contents($pipes[1]));
+ $returnCode = proc_close($proc);
+ $output = $stdout . $stderr;
+ }
+
+ if ($returnCode === 0) {
+ $image = new \OCP\Image();
+ $image->loadFromFile($tmpPath);
+ if ($image->valid()) {
+ unlink($tmpPath);
+ $image->scaleDownToFit($maxX, $maxY);
+
+ return $image;
+ }
+ }
+
+ if ($second === 0) {
+ $logger = Server::get(LoggerInterface::class);
+ $logger->info('Movie preview generation failed Output: {output}', ['app' => 'core', 'output' => $output]);
+ }
+
+ unlink($tmpPath);
+ return null;
+ }
+}
diff --git a/lib/private/Preview/Office.php b/lib/private/Preview/Office.php
new file mode 100644
index 00000000000..ffba0211de2
--- /dev/null
+++ b/lib/private/Preview/Office.php
@@ -0,0 +1,96 @@
+<?php
+
+/**
+ * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+namespace OC\Preview;
+
+use OCP\Files\File;
+use OCP\Files\FileInfo;
+use OCP\IImage;
+use OCP\ITempManager;
+use OCP\Server;
+use Psr\Log\LoggerInterface;
+
+abstract class Office extends ProviderV2 {
+ /**
+ * {@inheritDoc}
+ */
+ public function isAvailable(FileInfo $file): bool {
+ return is_string($this->options['officeBinary']);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function getThumbnail(File $file, int $maxX, int $maxY): ?IImage {
+ if (!$this->isAvailable($file)) {
+ return null;
+ }
+
+ $tempManager = Server::get(ITempManager::class);
+
+ // The file to generate the preview for.
+ $absPath = $this->getLocalFile($file);
+ if ($absPath === false) {
+ Server::get(LoggerInterface::class)->error(
+ 'Failed to get local file to generate thumbnail for: ' . $file->getPath(),
+ ['app' => 'core']
+ );
+ return null;
+ }
+
+ // The destination for the LibreOffice user profile.
+ // LibreOffice can rune once per user profile and therefore instance id and file id are included.
+ $profile = $tempManager->getTemporaryFolder(
+ 'nextcloud-office-profile-' . \OC_Util::getInstanceId() . '-' . $file->getId()
+ );
+
+ // The destination for the LibreOffice convert result.
+ $outdir = $tempManager->getTemporaryFolder(
+ 'nextcloud-office-preview-' . \OC_Util::getInstanceId() . '-' . $file->getId()
+ );
+
+ if ($profile === false || $outdir === false) {
+ $this->cleanTmpFiles();
+ return null;
+ }
+
+ $parameters = [
+ $this->options['officeBinary'],
+ '-env:UserInstallation=file://' . escapeshellarg($profile),
+ '--headless',
+ '--nologo',
+ '--nofirststartwizard',
+ '--invisible',
+ '--norestore',
+ '--convert-to png',
+ '--outdir ' . escapeshellarg($outdir),
+ escapeshellarg($absPath),
+ ];
+
+ $cmd = implode(' ', $parameters);
+ exec($cmd, $output, $returnCode);
+
+ if ($returnCode !== 0) {
+ $this->cleanTmpFiles();
+ return null;
+ }
+
+ $preview = $outdir . pathinfo($absPath, PATHINFO_FILENAME) . '.png';
+
+ $image = new \OCP\Image();
+ $image->loadFromFile($preview);
+
+ $this->cleanTmpFiles();
+
+ if ($image->valid()) {
+ $image->scaleDownToFit($maxX, $maxY);
+ return $image;
+ }
+
+ return null;
+ }
+}
diff --git a/lib/private/Preview/OpenDocument.php b/lib/private/Preview/OpenDocument.php
new file mode 100644
index 00000000000..f590eb6a59c
--- /dev/null
+++ b/lib/private/Preview/OpenDocument.php
@@ -0,0 +1,33 @@
+<?php
+
+/**
+ * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+namespace OC\Preview;
+
+//.odt, .ott, .oth, .odm, .odg, .otg, .odp, .otp, .ods, .ots, .odc, .odf, .odb, .odi, .oxt
+use OCP\Files\File;
+use OCP\IImage;
+
+class OpenDocument extends Bundled {
+ /**
+ * {@inheritDoc}
+ */
+ public function getMimeType(): string {
+ return '/application\/vnd.oasis.opendocument.*/';
+ }
+
+
+ /**
+ * @inheritDoc
+ */
+ public function getThumbnail(File $file, int $maxX, int $maxY): ?IImage {
+ $image = $this->extractThumbnail($file, 'Thumbnails/thumbnail.png');
+ if (($image !== null) && $image->valid()) {
+ return $image;
+ }
+ return null;
+ }
+}
diff --git a/lib/private/Preview/PDF.php b/lib/private/Preview/PDF.php
new file mode 100644
index 00000000000..9de14685925
--- /dev/null
+++ b/lib/private/Preview/PDF.php
@@ -0,0 +1,25 @@
+<?php
+
+/**
+ * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+namespace OC\Preview;
+
+//.pdf
+class PDF extends Bitmap {
+ /**
+ * {@inheritDoc}
+ */
+ public function getMimeType(): string {
+ return '/application\/pdf/';
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ protected function getAllowedMimeTypes(): string {
+ return '/application\/pdf/';
+ }
+}
diff --git a/lib/private/Preview/PNG.php b/lib/private/Preview/PNG.php
new file mode 100644
index 00000000000..49d0cd059ba
--- /dev/null
+++ b/lib/private/Preview/PNG.php
@@ -0,0 +1,17 @@
+<?php
+
+/**
+ * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+namespace OC\Preview;
+
+class PNG extends Image {
+ /**
+ * {@inheritDoc}
+ */
+ public function getMimeType(): string {
+ return '/image\/png/';
+ }
+}
diff --git a/lib/private/Preview/Photoshop.php b/lib/private/Preview/Photoshop.php
new file mode 100644
index 00000000000..b7209120530
--- /dev/null
+++ b/lib/private/Preview/Photoshop.php
@@ -0,0 +1,25 @@
+<?php
+
+/**
+ * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+namespace OC\Preview;
+
+//.psd
+class Photoshop extends Bitmap {
+ /**
+ * {@inheritDoc}
+ */
+ public function getMimeType(): string {
+ return '/application\/x-photoshop/';
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ protected function getAllowedMimeTypes(): string {
+ return '/(application|image)\/(x-photoshop|x-psd)/';
+ }
+}
diff --git a/lib/private/Preview/Postscript.php b/lib/private/Preview/Postscript.php
new file mode 100644
index 00000000000..04c667926aa
--- /dev/null
+++ b/lib/private/Preview/Postscript.php
@@ -0,0 +1,25 @@
+<?php
+
+/**
+ * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+namespace OC\Preview;
+
+//.eps
+class Postscript extends Bitmap {
+ /**
+ * {@inheritDoc}
+ */
+ public function getMimeType(): string {
+ return '/application\/postscript/';
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ protected function getAllowedMimeTypes(): string {
+ return '/application\/postscript/';
+ }
+}
diff --git a/lib/private/Preview/Provider.php b/lib/private/Preview/Provider.php
new file mode 100644
index 00000000000..26f0ac09f08
--- /dev/null
+++ b/lib/private/Preview/Provider.php
@@ -0,0 +1,50 @@
+<?php
+
+/**
+ * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+namespace OC\Preview;
+
+use OCP\Preview\IProvider;
+
+abstract class Provider implements IProvider {
+ private $options;
+
+ /**
+ * Constructor
+ *
+ * @param array $options
+ */
+ public function __construct(array $options = []) {
+ $this->options = $options;
+ }
+
+ /**
+ * @return string Regex with the mimetypes that are supported by this provider
+ */
+ abstract public function getMimeType();
+
+ /**
+ * Check if a preview can be generated for $path
+ *
+ * @param \OCP\Files\FileInfo $file
+ * @return bool
+ */
+ public function isAvailable(\OCP\Files\FileInfo $file) {
+ return true;
+ }
+
+ /**
+ * Generates thumbnail which fits in $maxX and $maxY and keeps the aspect ratio, for file at path $path
+ *
+ * @param string $path Path of file
+ * @param int $maxX The maximum X size of the thumbnail. It can be smaller depending on the shape of the image
+ * @param int $maxY The maximum Y size of the thumbnail. It can be smaller depending on the shape of the image
+ * @param bool $scalingup Disable/Enable upscaling of previews
+ * @param \OC\Files\View $fileview fileview object of user folder
+ * @return bool|\OCP\IImage false if no preview was generated
+ */
+ abstract public function getThumbnail($path, $maxX, $maxY, $scalingup, $fileview);
+}
diff --git a/lib/private/Preview/ProviderV1Adapter.php b/lib/private/Preview/ProviderV1Adapter.php
new file mode 100644
index 00000000000..ba8826ef765
--- /dev/null
+++ b/lib/private/Preview/ProviderV1Adapter.php
@@ -0,0 +1,45 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OC\Preview;
+
+use OC\Files\View;
+use OCP\Files\File;
+use OCP\Files\FileInfo;
+use OCP\IImage;
+use OCP\Preview\IProvider;
+use OCP\Preview\IProviderV2;
+
+class ProviderV1Adapter implements IProviderV2 {
+ private $providerV1;
+
+ public function __construct(IProvider $providerV1) {
+ $this->providerV1 = $providerV1;
+ }
+
+ public function getMimeType(): string {
+ return (string)$this->providerV1->getMimeType();
+ }
+
+ public function isAvailable(FileInfo $file): bool {
+ return (bool)$this->providerV1->isAvailable($file);
+ }
+
+ public function getThumbnail(File $file, int $maxX, int $maxY): ?IImage {
+ [$view, $path] = $this->getViewAndPath($file);
+ $thumbnail = $this->providerV1->getThumbnail($path, $maxX, $maxY, false, $view);
+ return $thumbnail === false ? null: $thumbnail;
+ }
+
+ private function getViewAndPath(File $file) {
+ $view = new View(dirname($file->getPath()));
+ $path = $file->getName();
+
+ return [$view, $path];
+ }
+}
diff --git a/lib/private/Preview/ProviderV2.php b/lib/private/Preview/ProviderV2.php
new file mode 100644
index 00000000000..556d1099d2d
--- /dev/null
+++ b/lib/private/Preview/ProviderV2.php
@@ -0,0 +1,108 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OC\Preview;
+
+use OCP\Files\File;
+use OCP\Files\FileInfo;
+use OCP\IImage;
+use OCP\ITempManager;
+use OCP\Preview\IProviderV2;
+use OCP\Server;
+use Psr\Log\LoggerInterface;
+
+abstract class ProviderV2 implements IProviderV2 {
+ protected array $tmpFiles = [];
+
+ public function __construct(
+ protected array $options = [],
+ ) {
+ }
+
+ /**
+ * @return string Regex with the mimetypes that are supported by this provider
+ */
+ abstract public function getMimeType(): string ;
+
+ /**
+ * Check if a preview can be generated for $path
+ *
+ * @param FileInfo $file
+ * @return bool
+ */
+ public function isAvailable(FileInfo $file): bool {
+ return true;
+ }
+
+ /**
+ * get thumbnail for file at path $path
+ *
+ * @param File $file
+ * @param int $maxX The maximum X size of the thumbnail. It can be smaller depending on the shape of the image
+ * @param int $maxY The maximum Y size of the thumbnail. It can be smaller depending on the shape of the image
+ * @return null|\OCP\IImage null if no preview was generated
+ * @since 17.0.0
+ */
+ abstract public function getThumbnail(File $file, int $maxX, int $maxY): ?IImage;
+
+ protected function useTempFile(File $file): bool {
+ return $file->isEncrypted() || !$file->getStorage()->isLocal();
+ }
+
+ /**
+ * Get a path to either the local file or temporary file
+ *
+ * @param File $file
+ * @param ?int $maxSize maximum size for temporary files
+ */
+ protected function getLocalFile(File $file, ?int $maxSize = null): string|false {
+ if ($this->useTempFile($file)) {
+ $absPath = Server::get(ITempManager::class)->getTemporaryFile();
+
+ if ($absPath === false) {
+ Server::get(LoggerInterface::class)->error(
+ 'Failed to get local file to generate thumbnail for: ' . $file->getPath(),
+ ['app' => 'core']
+ );
+ return false;
+ }
+
+ $content = $file->fopen('r');
+ if ($content === false) {
+ return false;
+ }
+
+ if ($maxSize) {
+ $content = stream_get_contents($content, $maxSize);
+ }
+
+ file_put_contents($absPath, $content);
+ $this->tmpFiles[] = $absPath;
+ return $absPath;
+ } else {
+ $path = $file->getStorage()->getLocalFile($file->getInternalPath());
+ if (is_string($path)) {
+ return $path;
+ } else {
+ return false;
+ }
+ }
+ }
+
+ /**
+ * Clean any generated temporary files
+ */
+ protected function cleanTmpFiles(): void {
+ foreach ($this->tmpFiles as $tmpFile) {
+ unlink($tmpFile);
+ }
+
+ $this->tmpFiles = [];
+ }
+}
diff --git a/lib/private/Preview/SGI.php b/lib/private/Preview/SGI.php
new file mode 100644
index 00000000000..78b1ea5828a
--- /dev/null
+++ b/lib/private/Preview/SGI.php
@@ -0,0 +1,24 @@
+<?php
+
+/**
+ * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OC\Preview;
+
+//.sgi
+class SGI extends Bitmap {
+ /**
+ * {@inheritDoc}
+ */
+ public function getMimeType(): string {
+ return '/image\/(x-)?sgi/';
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ protected function getAllowedMimeTypes(): string {
+ return '/image\/(x-)?sgi/';
+ }
+}
diff --git a/lib/private/Preview/SVG.php b/lib/private/Preview/SVG.php
new file mode 100644
index 00000000000..d9f7701f411
--- /dev/null
+++ b/lib/private/Preview/SVG.php
@@ -0,0 +1,70 @@
+<?php
+
+/**
+ * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+namespace OC\Preview;
+
+use OCP\Files\File;
+use OCP\IImage;
+use Psr\Log\LoggerInterface;
+
+class SVG extends ProviderV2 {
+ /**
+ * {@inheritDoc}
+ */
+ public function getMimeType(): string {
+ return '/image\/svg\+xml/';
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function getThumbnail(File $file, int $maxX, int $maxY): ?IImage {
+ try {
+ $content = stream_get_contents($file->fopen('r'));
+ if (substr($content, 0, 5) !== '<?xml') {
+ $content = '<?xml version="1.0" encoding="UTF-8" standalone="no"?>' . $content;
+ }
+
+ // Do not parse SVG files with references
+ if (preg_match('/["\s](xlink:)?href\s*=/i', $content)) {
+ return null;
+ }
+
+ $svg = new \Imagick();
+
+ $svg->pingImageBlob($content);
+ $mimeType = $svg->getImageMimeType();
+ if (!preg_match($this->getMimeType(), $mimeType)) {
+ throw new \Exception('File mime type does not match the preview provider: ' . $mimeType);
+ }
+
+ $svg->setBackgroundColor(new \ImagickPixel('transparent'));
+ $svg->readImageBlob($content);
+ $svg->setImageFormat('png32');
+ } catch (\Exception $e) {
+ \OC::$server->get(LoggerInterface::class)->error(
+ 'File: ' . $file->getPath() . ' Imagick says:',
+ [
+ 'exception' => $e,
+ 'app' => 'core',
+ ]
+ );
+ return null;
+ }
+
+ //new image object
+ $image = new \OCP\Image();
+ $image->loadFromData((string)$svg);
+ //check if image object is valid
+ if ($image->valid()) {
+ $image->scaleDownToFit($maxX, $maxY);
+
+ return $image;
+ }
+ return null;
+ }
+}
diff --git a/lib/private/Preview/StarOffice.php b/lib/private/Preview/StarOffice.php
new file mode 100644
index 00000000000..9ea540dc912
--- /dev/null
+++ b/lib/private/Preview/StarOffice.php
@@ -0,0 +1,18 @@
+<?php
+
+/**
+ * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+namespace OC\Preview;
+
+//.sxw, .stw, .sxc, .stc, .sxd, .std, .sxi, .sti, .sxg, .sxm
+class StarOffice extends Office {
+ /**
+ * {@inheritDoc}
+ */
+ public function getMimeType(): string {
+ return '/application\/vnd.sun.xml.*/';
+ }
+}
diff --git a/lib/private/Preview/Storage/Root.php b/lib/private/Preview/Storage/Root.php
new file mode 100644
index 00000000000..41378653962
--- /dev/null
+++ b/lib/private/Preview/Storage/Root.php
@@ -0,0 +1,74 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OC\Preview\Storage;
+
+use OC\Files\AppData\AppData;
+use OC\SystemConfig;
+use OCP\Files\IRootFolder;
+use OCP\Files\NotFoundException;
+use OCP\Files\SimpleFS\ISimpleFolder;
+
+class Root extends AppData {
+ private $isMultibucketPreviewDistributionEnabled = false;
+ public function __construct(IRootFolder $rootFolder, SystemConfig $systemConfig) {
+ parent::__construct($rootFolder, $systemConfig, 'preview');
+
+ $this->isMultibucketPreviewDistributionEnabled = $systemConfig->getValue('objectstore.multibucket.preview-distribution', false) === true;
+ }
+
+
+ public function getFolder(string $name): ISimpleFolder {
+ $internalFolder = self::getInternalFolder($name);
+
+ try {
+ return parent::getFolder($internalFolder);
+ } catch (NotFoundException $e) {
+ /*
+ * The new folder structure is not found.
+ * Lets try the old one
+ */
+ }
+
+ try {
+ return parent::getFolder($name);
+ } catch (NotFoundException $e) {
+ /*
+ * The old folder structure is not found.
+ * Lets try the multibucket fallback if available
+ */
+ if ($this->isMultibucketPreviewDistributionEnabled) {
+ return parent::getFolder('old-multibucket/' . $internalFolder);
+ }
+
+ // when there is no further fallback just throw the exception
+ throw $e;
+ }
+ }
+
+ public function newFolder(string $name): ISimpleFolder {
+ $internalFolder = self::getInternalFolder($name);
+ return parent::newFolder($internalFolder);
+ }
+
+ /*
+ * Do not allow directory listing on this special root
+ * since it gets to big and time consuming
+ */
+ public function getDirectoryListing(): array {
+ return [];
+ }
+
+ public static function getInternalFolder(string $name): string {
+ return implode('/', str_split(substr(md5($name), 0, 7))) . '/' . $name;
+ }
+
+ public function getStorageId(): int {
+ return $this->getAppDataRootFolder()->getStorage()->getCache()->getNumericStorageId();
+ }
+}
diff --git a/lib/private/Preview/TGA.php b/lib/private/Preview/TGA.php
new file mode 100644
index 00000000000..675907b4e49
--- /dev/null
+++ b/lib/private/Preview/TGA.php
@@ -0,0 +1,24 @@
+<?php
+
+/**
+ * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OC\Preview;
+
+//.tga
+class TGA extends Bitmap {
+ /**
+ * {@inheritDoc}
+ */
+ public function getMimeType(): string {
+ return '/image\/(x-)?t(ar)?ga/';
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ protected function getAllowedMimeTypes(): string {
+ return '/image\/(x-)?t(ar)?ga/';
+ }
+}
diff --git a/lib/private/Preview/TIFF.php b/lib/private/Preview/TIFF.php
new file mode 100644
index 00000000000..cd81e611d0b
--- /dev/null
+++ b/lib/private/Preview/TIFF.php
@@ -0,0 +1,25 @@
+<?php
+
+/**
+ * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+namespace OC\Preview;
+
+//.tiff
+class TIFF extends Bitmap {
+ /**
+ * {@inheritDoc}
+ */
+ public function getMimeType(): string {
+ return '/image\/tiff/';
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ protected function getAllowedMimeTypes(): string {
+ return '/image\/tiff/';
+ }
+}
diff --git a/lib/private/Preview/TXT.php b/lib/private/Preview/TXT.php
new file mode 100644
index 00000000000..1a1d64f3e08
--- /dev/null
+++ b/lib/private/Preview/TXT.php
@@ -0,0 +1,89 @@
+<?php
+
+/**
+ * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+namespace OC\Preview;
+
+use OCP\Files\File;
+use OCP\Files\FileInfo;
+use OCP\IImage;
+
+class TXT extends ProviderV2 {
+ /**
+ * {@inheritDoc}
+ */
+ public function getMimeType(): string {
+ return '/text\/plain/';
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function isAvailable(FileInfo $file): bool {
+ return $file->getSize() > 0;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function getThumbnail(File $file, int $maxX, int $maxY): ?IImage {
+ if (!$this->isAvailable($file)) {
+ return null;
+ }
+
+ $content = $file->fopen('r');
+
+ if ($content === false) {
+ return null;
+ }
+
+ $content = stream_get_contents($content, 3000);
+
+ //don't create previews of empty text files
+ if (trim($content) === '') {
+ return null;
+ }
+
+ $lines = preg_split("/\r\n|\n|\r/", $content);
+
+ // Define text size of text file preview
+ $fontSize = $maxX ? (int)((1 / 32) * $maxX) : 5; //5px
+ $lineSize = ceil($fontSize * 1.5);
+
+ $image = imagecreate($maxX, $maxY);
+ imagecolorallocate($image, 255, 255, 255);
+ $textColor = imagecolorallocate($image, 0, 0, 0);
+
+ $fontFile = __DIR__;
+ $fontFile .= '/../../../core';
+ $fontFile .= '/fonts/NotoSans-Regular.ttf';
+
+ $canUseTTF = function_exists('imagettftext');
+
+ foreach ($lines as $index => $line) {
+ $index = $index + 1;
+
+ $x = 1;
+ $y = (int)($index * $lineSize);
+
+ if ($canUseTTF === true) {
+ imagettftext($image, $fontSize, 0, $x, $y, $textColor, $fontFile, $line);
+ } else {
+ $y -= $fontSize;
+ imagestring($image, 1, $x, $y, $line, $textColor);
+ }
+
+ if (($index * $lineSize) >= $maxY) {
+ break;
+ }
+ }
+
+ $imageObject = new \OCP\Image();
+ $imageObject->setResource($image);
+
+ return $imageObject->valid() ? $imageObject : null;
+ }
+}
diff --git a/lib/private/Preview/Watcher.php b/lib/private/Preview/Watcher.php
new file mode 100644
index 00000000000..21f040d8342
--- /dev/null
+++ b/lib/private/Preview/Watcher.php
@@ -0,0 +1,63 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OC\Preview;
+
+use OCP\Files\FileInfo;
+use OCP\Files\Folder;
+use OCP\Files\IAppData;
+use OCP\Files\Node;
+use OCP\Files\NotFoundException;
+
+/**
+ * Class Watcher
+ *
+ * @package OC\Preview
+ *
+ * Class that will watch filesystem activity and remove previews as needed.
+ */
+class Watcher {
+ /** @var IAppData */
+ private $appData;
+
+ /**
+ * Watcher constructor.
+ *
+ * @param IAppData $appData
+ */
+ public function __construct(IAppData $appData) {
+ $this->appData = $appData;
+ }
+
+ public function postWrite(Node $node) {
+ $this->deleteNode($node);
+ }
+
+ protected function deleteNode(FileInfo $node) {
+ // We only handle files
+ if ($node instanceof Folder) {
+ return;
+ }
+
+ try {
+ if (is_null($node->getId())) {
+ return;
+ }
+ $folder = $this->appData->getFolder((string)$node->getId());
+ $folder->delete();
+ } catch (NotFoundException $e) {
+ //Nothing to do
+ }
+ }
+
+ public function versionRollback(array $data) {
+ if (isset($data['node'])) {
+ $this->deleteNode($data['node']);
+ }
+ }
+}
diff --git a/lib/private/Preview/WatcherConnector.php b/lib/private/Preview/WatcherConnector.php
new file mode 100644
index 00000000000..c34dd1dde4d
--- /dev/null
+++ b/lib/private/Preview/WatcherConnector.php
@@ -0,0 +1,41 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OC\Preview;
+
+use OC\SystemConfig;
+use OCA\Files_Versions\Events\VersionRestoredEvent;
+use OCP\EventDispatcher\IEventDispatcher;
+use OCP\Files\IRootFolder;
+use OCP\Files\Node;
+
+class WatcherConnector {
+ public function __construct(
+ private IRootFolder $root,
+ private SystemConfig $config,
+ private IEventDispatcher $dispatcher,
+ ) {
+ }
+
+ private function getWatcher(): Watcher {
+ return \OCP\Server::get(Watcher::class);
+ }
+
+ public function connectWatcher(): void {
+ // Do not connect if we are not setup yet!
+ if ($this->config->getValue('instanceid', null) !== null) {
+ $this->root->listen('\OC\Files', 'postWrite', function (Node $node) {
+ $this->getWatcher()->postWrite($node);
+ });
+
+ $this->dispatcher->addListener(VersionRestoredEvent::class, function (VersionRestoredEvent $event) {
+ $this->getWatcher()->versionRollback(['node' => $event->getVersion()->getSourceFile()]);
+ });
+ }
+ }
+}
diff --git a/lib/private/Preview/WebP.php b/lib/private/Preview/WebP.php
new file mode 100644
index 00000000000..25b922e9190
--- /dev/null
+++ b/lib/private/Preview/WebP.php
@@ -0,0 +1,24 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+namespace OC\Preview;
+
+use OCP\Files\FileInfo;
+
+class WebP extends Image {
+ /**
+ * {@inheritDoc}
+ */
+ public function getMimeType(): string {
+ return '/image\/webp/';
+ }
+
+ public function isAvailable(FileInfo $file): bool {
+ return (bool)(imagetypes() & IMG_WEBP);
+ }
+}
diff --git a/lib/private/Preview/XBitmap.php b/lib/private/Preview/XBitmap.php
new file mode 100644
index 00000000000..c8337cc252d
--- /dev/null
+++ b/lib/private/Preview/XBitmap.php
@@ -0,0 +1,17 @@
+<?php
+
+/**
+ * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+namespace OC\Preview;
+
+class XBitmap extends Image {
+ /**
+ * {@inheritDoc}
+ */
+ public function getMimeType(): string {
+ return '/image\/x-xbitmap/';
+ }
+}