Resize avatar images before embedding
Source media files can easily be multiple megabytes — embedding the originals made a per-recipient email balloon to 10MB+. Each avatar is now cover-cropped to 96x96 (HiDPI for the rendered 48px circle) and re-encoded as JPEG q=75 via Intervention\Image, which webtrees already depends on. Typical avatar payload drops from megabytes to ~5-15KB. Falls back to the original bytes (with a log warning) if neither Imagick nor GD is loaded — better an oversized email than none.
This commit is contained in:
@@ -17,6 +17,10 @@ use Fisharebest\Webtrees\Services\UserService;
|
||||
use Fisharebest\Webtrees\Tree;
|
||||
use Fisharebest\Webtrees\User;
|
||||
use Illuminate\Support\Collection;
|
||||
use Intervention\Image\Drivers\Gd\Driver as GdDriver;
|
||||
use Intervention\Image\Drivers\Imagick\Driver as ImagickDriver;
|
||||
use Intervention\Image\Encoders\JpegEncoder;
|
||||
use Intervention\Image\ImageManager;
|
||||
use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
|
||||
use Symfony\Component\Mime\Exception\RfcComplianceException;
|
||||
use Throwable;
|
||||
@@ -38,6 +42,22 @@ final class NewsletterDispatchService
|
||||
*/
|
||||
private const string FROM_NAME = 'webtrees newsletter';
|
||||
|
||||
/**
|
||||
* Target dimensions for embedded avatars, in pixels. Rendered at 48
|
||||
* CSS pixels, so 96 covers HiDPI displays. Larger source images are
|
||||
* cover-cropped down; smaller ones are left untouched.
|
||||
*/
|
||||
private const int AVATAR_SIZE = 96;
|
||||
|
||||
/**
|
||||
* JPEG quality used when re-encoding resized avatars. 75 is a good
|
||||
* size/quality trade-off for small portraits.
|
||||
*/
|
||||
private const int AVATAR_JPEG_QUALITY = 75;
|
||||
|
||||
private ImageManager|null $image_manager = null;
|
||||
private bool $image_manager_resolved = false;
|
||||
|
||||
public function __construct(
|
||||
private readonly EventQueryService $event_query_service,
|
||||
private readonly NewsletterMailer $mailer,
|
||||
@@ -293,12 +313,80 @@ final class NewsletterDispatchService
|
||||
return null;
|
||||
}
|
||||
|
||||
$resized = $this->resizeAvatar($bytes);
|
||||
|
||||
if ($resized !== null) {
|
||||
return $resized;
|
||||
}
|
||||
|
||||
// Fall back to the original bytes if no image library is
|
||||
// available — better an oversized email than no avatar at all.
|
||||
return [
|
||||
'bytes' => $bytes,
|
||||
'mime' => $media_file->mimeType() ?: 'application/octet-stream',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Cover-crop the source image to a small square and re-encode it
|
||||
* as JPEG. Drops a multi-MB source down to ~5–15 KB.
|
||||
*
|
||||
* Returns null if no image library is loaded (in which case the
|
||||
* caller keeps the original bytes), or if Intervention failed to
|
||||
* read the file.
|
||||
*
|
||||
* @return array{bytes:string,mime:string}|null
|
||||
*/
|
||||
private function resizeAvatar(string $bytes): array|null
|
||||
{
|
||||
$manager = $this->imageManager();
|
||||
|
||||
if ($manager === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$image = $manager->read($bytes);
|
||||
$encoded = $image
|
||||
->cover(self::AVATAR_SIZE, self::AVATAR_SIZE)
|
||||
->encode(new JpegEncoder(self::AVATAR_JPEG_QUALITY))
|
||||
->toString();
|
||||
} catch (Throwable $ex) {
|
||||
Log::addErrorLog('Newsletter avatar resize failed: ' . $ex->getMessage());
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'bytes' => $encoded,
|
||||
'mime' => 'image/jpeg',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazily build (and cache) an Intervention ImageManager, preferring
|
||||
* Imagick over GD — mirrors what webtrees' own ImageFactory does.
|
||||
* Returns null if neither extension is present so the caller can
|
||||
* gracefully fall back to the original image bytes.
|
||||
*/
|
||||
private function imageManager(): ImageManager|null
|
||||
{
|
||||
if (!$this->image_manager_resolved) {
|
||||
$this->image_manager_resolved = true;
|
||||
|
||||
if (extension_loaded('imagick')) {
|
||||
$this->image_manager = new ImageManager(new ImagickDriver());
|
||||
} elseif (extension_loaded('gd')) {
|
||||
$this->image_manager = new ImageManager(new GdDriver());
|
||||
} else {
|
||||
Log::addErrorLog(
|
||||
'Newsletter: neither Imagick nor GD is available; avatars will be embedded at their original size.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->image_manager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the map the view consults: xref -> CID name. Only entries
|
||||
* for individuals with a successfully resolved avatar are present;
|
||||
|
||||
Reference in New Issue
Block a user