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\Tree;
|
||||||
use Fisharebest\Webtrees\User;
|
use Fisharebest\Webtrees\User;
|
||||||
use Illuminate\Support\Collection;
|
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\Mailer\Exception\TransportExceptionInterface;
|
||||||
use Symfony\Component\Mime\Exception\RfcComplianceException;
|
use Symfony\Component\Mime\Exception\RfcComplianceException;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
@@ -38,6 +42,22 @@ final class NewsletterDispatchService
|
|||||||
*/
|
*/
|
||||||
private const string FROM_NAME = 'webtrees newsletter';
|
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(
|
public function __construct(
|
||||||
private readonly EventQueryService $event_query_service,
|
private readonly EventQueryService $event_query_service,
|
||||||
private readonly NewsletterMailer $mailer,
|
private readonly NewsletterMailer $mailer,
|
||||||
@@ -293,12 +313,80 @@ final class NewsletterDispatchService
|
|||||||
return null;
|
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 [
|
return [
|
||||||
'bytes' => $bytes,
|
'bytes' => $bytes,
|
||||||
'mime' => $media_file->mimeType() ?: 'application/octet-stream',
|
'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
|
* Build the map the view consults: xref -> CID name. Only entries
|
||||||
* for individuals with a successfully resolved avatar are present;
|
* for individuals with a successfully resolved avatar are present;
|
||||||
|
|||||||
Reference in New Issue
Block a user