Conversions & PDF pages

Conversions

A conversion is a named version of an image, defined once in config/uploads.php:

'conversions' => [
    'thumb' => [
        'fit' => 'cover',
        'format' => 'webp',
        'height' => 300,
        'quality' => 80,
        'width' => 300,
    ],
    'preview' => [
        'fit' => 'contain',
        'format' => 'webp',
        'height' => 1200,
        'quality' => 82,
        'width' => 1200,
    ],
],
Key Values Meaning
fit contain, cover contain scales the image down so it fits inside the box without cropping; cover crops it to fill the box exactly
format jpg, png, webp The file that is written
width, height pixels or null The box; with one side null, contain keeps the ratio and cover behaves like contain
quality 1 to 100 JPEG and WebP quality

An image is never enlarged: a 200 pixel original gets a 200 pixel preview. Conversions are made with Intervention Image 3 on the GD driver, which reads JPEG, PNG, GIF, WebP, BMP and AVIF when PHP’s GD supports them. Orientation from EXIF is applied.

Add a conversion by adding a key. The job makes every conversion in the array for every image upload, so keep the list to what the site shows: each one is a file on the disk.

Where the files go

The job writes next to the original on the same disk:

products/12/photos/9f1c…-thumb.webp
products/12/photos/9f1c…-preview.webp

A private upload keeps its conversions private, because the route uploads.show serves them with ?conversion=thumb, with the same policy as the original. The paths are kept in the conversions column:

$upload->conversions;
// ['thumb' => 'products/12/photos/9f1c…-thumb.webp', 'preview' => '…-preview.webp']

Showing a conversion

<img src="<?= e($upload->conversionUrl('thumb')) ?>">
<img src="<?= e($upload->thumbnailUrl()) ?>">   <!-- the same -->

While the job has not run yet, conversionUrl() returns the original, so a gallery shows the photo right after the upload and switches to the thumbnail on the next page load. The Livewire upload manager polls every five seconds while an upload in its list is not processed.

The job

Darvis\Uploads\Jobs\ProcessUpload is dispatched by StoreUpload on the connection and queue from uploads.queue. It copies the original to a temporary file, renders the PDF pages when the upload is a PDF, makes every conversion, writes everything to the disk, fills conversions and processed_at, removes the temporary files and dispatches Darvis\Uploads\Events\UploadProcessed. A file that is neither an image nor a PDF only gets processed_at.

The job tries three times. When it keeps failing, the upload stays with processed_at = null and the site keeps showing the original; look at failed_jobs for the reason.

Adding a conversion later

Existing uploads do not get a new conversion by themselves. Dispatch the job again for the uploads that need it:

use Darvis\Uploads\Jobs\ProcessUpload;
use Darvis\Uploads\Models\Upload;

Upload::images()->each(fn (Upload $upload) => ProcessUpload::dispatchFor($upload));

The job makes every conversion again and overwrites the files with the same name; a conversion that was removed from the config keeps its old file on the disk until the upload is hard deleted, because deleteFiles() reads the paths from the conversions column that the job rewrites. Remove such files by hand when that matters.

PDF pages

A PDF upload is rendered to one image per page, up to pdf.max_pages (50), at pdf.dpi (110) in pdf.format (jpg). The pages are stored next to the original as …-page-1.jpg, …-page-2.jpg and kept under pages in the conversions column. The first page then goes through the conversions like an image, so a PDF has a thumb and a preview.

<img src="<?= e($manual->thumbnailUrl()) ?>">

@for ($page = 1; $page <= $manual->pageCount(); $page++)
    <img src="<?= e($manual->pageUrl($page)) ?>" alt="Page <?= e($page) ?>">
@endfor

pageUrl() returns null for a page that was not rendered, for example page 60 of a 60 page PDF with max_pages at 50. Set max_pages to 0 to skip the pages and only make the thumbnail of the first page.

Drivers

pdf.driver Needs Notes
imagick the Imagick extension with Ghostscript Renders in PHP, no process is started
pdftoppm the pdftoppm binary from poppler-utils Started with Symfony Process, five minute timeout; pdf.pdftoppm_path may be an absolute path
auto either Imagick when it is loaded, otherwise pdftoppm when it is found in the PATH; when Imagick cannot read the PDF (no Ghostscript, or a policy that forbids PDF) pdftoppm takes over

Darvis\Uploads\Processing\PdfRasterizer::driver() tells which one will be used; isAvailable() whether any is. Without a driver the job throws Darvis\Uploads\Exceptions\PdfRenderingUnavailable and the PDF stays unprocessed; the file itself is stored and downloadable as usual.

On a Plesk server pdftoppm is usually at /usr/bin/pdftoppm once poppler-utils is installed; on a Mac with Homebrew at /opt/homebrew/bin/pdftoppm, which is not in the PATH of MAMP’s PHP, so set UPLOADS_PDFTOPPM_PATH to that path locally.