Distill Thumbnail from .afphoto and .afdesign

Nextcloud does not support generating thumbnails from Affinity Photo and Affinity Designer. Fine, I'll do it myself!

Digging Binary

Glancing at .afphoto and .afdesign in Finder, I noticed that it has a QuickLook support and an ability to show the thumbnail image. Meaning there's a chance that these files contain pre-generated thumbnail images somewhere inside its binaries, meaning I don't have to reverse-engineer their format from ground up.

To verify this, I wrote a piece of Node.js script to seek for PNG blob inside an .afphoto/.afdesign file and save it as a normal PNG file.

In the 11.2.1 General of the PNG spec, they stated a valid PNG image should begin with a PNG signature and end with an IEND chunk.

A valid PNG datastream shall begin with a PNG signature, immediately followed by an IHDR chunk, then one or more IDAT chunks, and shall end with an IEND chunk. Only one IHDR chunk and one IEND chunk are allowed in a PNG datastream.

Conveniently, it is also guaranteed that there should be only one IEND chunk in a PNG file, so greedy search would just work.

const fs = require("fs"); // png spec: https://www.w3.org/TR/PNG/ const PNG_SIG = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]); const IEND_SIG = Buffer.from([73, 69, 78, 68]); function extractPngBlob(buf) { const start = buf.indexOf(PNG_SIG); const end = buf.indexOf(IEND_SIG, start) + IEND_SIG.length * 2; // IEND + CRC return buf.subarray(start, end); } function extractThumbnail(input, output) { const buf = fs.readFileSync(input); const pngBlob = extractPngBlob(buf); fs.writeFileSync(output, pngBlob); } extractThumbnail(process.argv[2], process.argv[3] || "output.png");

That's right. This script just do indexOf on a Buffer and distill a portion of which starts with PNG signature and ends with IEND (+ CRC checksum).

CRC (Cyclic Redundancy Code)

You may have wondered about IEND_SIG.length * 2 part. It was to include 32-bit CRC (Cyclic Redundancy Code) for IEND to the resulting blob.

Here, the byte-length of IEND chunk and its CRC checksum are coincidentally the same (4 bytes), so I just went with that code.

Now I can generate a thumbnail image from arbitrary .afphoto and .afdesign file. Let's move on delving into Nextcloud source code.

Tweaking Nextcloud

At this point, all I have to do is to rewrite the above code in PHP and make them to behave as a Nextcloud Preview Provider.

<?php namespace OC\Preview; use OCP\Files\File; use OCP\IImage; use OCP\ILogger; class Affinity extends ProviderV2 { public function getMimeType(): string { return '/application\/x-affinity-(?:photo|design)/'; } public function getThumbnail(File $file, int $maxX, int $maxY): ?IImage { $tmpPath = $this->getLocalFile($file); $handle = fopen($tmpPath, 'rb'); $fsize = filesize($tmpPath); $contents = fread($handle, $fsize); $start = strrpos($contents, "\x89PNG"); $end = strrpos($contents, "IEND", $start); $subarr = substr($contents, $start, $end - $start + 8 ); fclose($handle); $this->cleanTmpFiles(); $image = new \OC_Image(); $image->loadFromData($subarr); $image->scaleDownToFit($maxX, $maxY); return $image->valid() ? $image : null; } }

Also make sure my component to be auto-loaded on startup.

@@ -363,6 +365,8 @@ $this->registerCoreProvider(Preview\Krita::class, '/application\/x-krita/'); $this->registerCoreProvider(Preview\MP3::class, '/audio\/mpeg/'); $this->registerCoreProvider(Preview\OpenDocument::class, '/application\/vnd.oasis.opendocument.*/'); + $this->registerCoreProvider(Preview\Affinity::class, '/application\/x-affinity-(?:photo|design)/'); // SVG, Office and Bitmap require imagick if (extension_loaded('imagick')) {
@@ -1226,6 +1226,7 @@ 'OC\\OCS\\Result' => __DIR__ . '/../../..' . '/lib/private/OCS/Result.php', 'OC\\PreviewManager' => __DIR__ . '/../../..' . '/lib/private/PreviewManager.php', 'OC\\PreviewNotAvailableException' => __DIR__ . '/../../..' . '/lib/private/PreviewNotAvailableException.php', + 'OC\\Preview\\Affinity' => __DIR__ . '/../../..' . '/lib/private/Preview/Affinity.php', 'OC\\Preview\\BMP' => __DIR__ . '/../../..' . '/lib/private/Preview/BMP.php', 'OC\\Preview\\BackgroundCleanupJob' => __DIR__ . '/../../..' . '/lib/private/Preview/BackgroundCleanupJob.php', 'OC\\Preview\\Bitmap' => __DIR__ . '/../../..' . '/lib/private/Preview/Bitmap.php',
@@ -1197,6 +1197,7 @@ 'OC\\OCS\\Result' => $baseDir . '/lib/private/OCS/Result.php', 'OC\\PreviewManager' => $baseDir . '/lib/private/PreviewManager.php', 'OC\\PreviewNotAvailableException' => $baseDir . '/lib/private/PreviewNotAvailableException.php', + 'OC\\Preview\\Affinity' => $baseDir . '/lib/private/Preview/Affinity.php', 'OC\\Preview\\BMP' => $baseDir . '/lib/private/Preview/BMP.php', 'OC\\Preview\\BackgroundCleanupJob' => $baseDir . '/lib/private/Preview/BackgroundCleanupJob.php', 'OC\\Preview\\Bitmap' => $baseDir . '/lib/private/Preview/Bitmap.php',

VoilĂ ! Now I can see beautiful thumbnails for my drawings in Nextcloud web interface.

This is exactly why I love FOSS. It allows me to materialize any niche things I want in the FOSS without bothering its developers. This fact not only gives me confidence that I can control the functionality of the software, but it also makes me have more trust in the developers for giving me such freedom to make changes to their software.

Finalized Solution

Enough talking, I've pushed my Nextcloud Docker setup with the above patches included on GitHub. You can see the actual patch here. Note that it also contains the patches for PDF thumbnail generator described below, and this particular patch may pose security implications because of the usage of Ghostscript against PDF.

Bonus: PDF thumbnail generator

Install ghostscript on your server to make it work.

<?php namespace OC\Preview; use OCP\Files\File; use OCP\IImage; class PDF extends ProviderV2 { public function getMimeType(): string { return '/application\/pdf/'; } public function getThumbnail(File $file, int $maxX, int $maxY): ?IImage { $tmpPath = $this->getLocalFile($file); $outputPath = \OC::$server->getTempManager()->getTemporaryFile(); $gsBin = \OC_Helper::findBinaryPath('gs'); $cmd = $gsBin . " -o " . escapeshellarg($outputPath) . " -sDEVICE=jpeg -sPAPERSIZE=a4 -dLastPage=1 -dPDFFitPage -dJPEGQ=90 -r144 " . escapeshellarg($tmpPath); shell_exec($cmd); $this->cleanTmpFiles(); $image = new \OC_Image(); $image->loadFromFile($outputPath); $image->scaleDownToFit($maxX, $maxY); unlink($outputPath); return $image->valid() ? $image : null; } }