روی هاست اشتراکی، توابع اجرای فرمان سیستمی معمولاً بسته‌اند. یعنی هر راه‌حل تصویری که به صدا زدن یک باینری خارجی متکی باشد، برای شما وجود ندارد. این محدودیت را در نوشته‌ی disable_functions کامل توضیح داده‌ایم.

پس می‌ماند GD: کتابخانه‌ای که در خود PHP تعبیه شده. این نوشته خط لوله‌ای است که برای همین سایت با GD ساختیم و سه محافظی که بدون آن‌ها کار بی‌صدا خراب می‌شود.

اول ببینید چه دارید#

PHP
var_dump(extension_loaded('gd'));
var_dump(function_exists('imagewebp'));
var_dump(function_exists('imagecreatefromavif'));
print_r(gd_info());

نکته: حضور افزونه یعنی همه‌ی قالب‌ها پشتیبانی نمی‌شوند. روی بعضی بیلدها WebP هست و AVIF نیست، یا برعکس. همیشه تابع مشخص را چک کنید نه فقط خود افزونه را.

محافظ یکم: حافظه، قبل از باز کردن فایل#

این مهم‌ترین بخش این نوشته است.

وقتی GD یک JPEG را باز می‌کند، آن را به آرایه‌ی پیکسل تبدیل می‌کند. حافظه‌ی لازم به تعداد پیکسل بستگی دارد، نه به حجم فایل. یک JPEG یک‌مگابایتی می‌تواند دوازده مگاپیکسل باشد و هنگام باز شدن ده‌ها برابر حجم فایلش حافظه بگیرد.

نتیجه‌اش بدترین نوع شکست است: پروسه بدون هیچ پیام قابل فهمی می‌میرد. کاربر دکمه را می‌زند، صفحه سفید می‌شود، هیچ‌جا خطایی ثبت نمی‌شود.

راه‌حل: قبل از باز کردن، تخمین بزنید و اگر جا نمی‌شود صریح رد کنید.

PHP
private static function assertCanOpen(string $path): void
{
    $info = @getimagesize($path);

    if ($info === false) {
        throw new RuntimeException('فایل تصویر خوانا نیست.');
    }

    [$width, $height] = $info;
    $channels = $info['channels'] ?? 4;
    $bits = $info['bits'] ?? 8;

    // تخمین محافظه‌کارانه با ضریب اطمینان
    $needed = (int) ($width * $height * ($bits / 8) * $channels * 1.8);
    $limit = self::memoryLimitBytes();
    $free = $limit - memory_get_usage(true);

    if ($limit > 0 && $needed > $free) {
        throw new RuntimeException('ابعاد تصویر برای حافظه‌ی موجود بزرگ است.');
    }
}

تابع getimagesize فقط سرآیند فایل را می‌خواند و تقریباً هیچ حافظه‌ای نمی‌گیرد، پس این چک امن است.

خواندن سقف حافظه هم چند حالت دارد که باید همه را پوشش دهید:

PHP
private static function memoryLimitBytes(): int
{
    $raw = trim((string) ini_get('memory_limit'));

    if ($raw === '' || $raw === '-1') {
        return 0; // بدون سقف
    }

    $unit = strtolower(substr($raw, -1));
    $value = (int) $raw;

    return match ($unit) {
        'g' => $value * 1024 * 1024 * 1024,
        'm' => $value * 1024 * 1024,
        'k' => $value * 1024,
        default => $value,
    };
}

محافظ دوم: چرخش EXIF#

عکس‌های موبایل معمولاً به‌صورت افقی ذخیره می‌شوند و جهت درست در فراداده‌ی EXIF نوشته می‌شود. مرورگر این فراداده را می‌خواند، GD نه.

نتیجه: عکسی که در گالری گوشی درست است، بعد از پردازش چرخیده از آب درمی‌آید.

PHP
private static function applyExifRotation($image, string $path)
{
    if (! function_exists('exif_read_data')) {
        return $image;
    }

    $exif = @exif_read_data($path);
    $orientation = (int) ($exif['Orientation'] ?? 1);

    return match ($orientation) {
        3 => imagerotate($image, 180, 0),
        6 => imagerotate($image, -90, 0),
        8 => imagerotate($image, 90, 0),
        default => $image,
    };
}

یک اثر جانبی مثبت هم دارد: خروجی GD فراداده را حمل نمی‌کند، پس مختصات جغرافیایی و نام دستگاه که در عکس موبایل ذخیره شده‌اند خودکار پاک می‌شوند. برای حریم خصوصی کاربران این ویژگی خوبی است.

محافظ سوم: شفافیت#

اگر تصویر PNG یا WebP شفاف را با تنظیمات پیش‌فرض تغییر اندازه دهید، پس‌زمینه‌ی شفاف سیاه می‌شود. دو خط لازم است و راحت فراموش می‌شوند:

PHP
$canvas = imagecreatetruecolor($newWidth, $newHeight);

imagealphablending($canvas, false);
imagesavealpha($canvas, true);

$transparent = imagecolorallocatealpha($canvas, 0, 0, 0, 127);
imagefilledrectangle($canvas, 0, 0, $newWidth, $newHeight, $transparent);

imagecopyresampled(
    $canvas, $source,
    0, 0, 0, 0,
    $newWidth, $newHeight, $srcWidth, $srcHeight
);

همیشه imagecopyresampled استفاده کنید نه imagecopyresized؛ دومی درون‌یابی نمی‌کند و نتیجه‌اش پله‌پله است.

کیفیت را بر اساس نوع تصویر انتخاب کنید#

این نکته را در هیچ آموزشی ندیدیم ولی برای یک بلاگ فنی حیاتی است.

تصویرهای شما دو دسته‌اند و نیاز کیفیتشان یکی نیست:

  • عکس واقعی — گرادیان نرم، بدون لبه‌ی تیز. فشرده‌سازی با اتلاف را خوب تحمل می‌کند.
  • اسکرین‌شات و گرافیک — متن ریز، خط‌های یک‌پیکسلی، لبه‌های تیز. همان فشرده‌سازی اینجا حروف را مخدوش می‌کند و کد داخل تصویر ناخوانا می‌شود.

اگر بلاگتان پر از اسکرین‌شات ترمینال است — مثل همین بلاگ — کیفیت یکسان برای همه یعنی یا حجم بی‌دلیل زیاد یا متن له‌شده. ما دو سطح کیفیت جدا گذاشتیم و انتخاب را به آپلودکننده سپردیم:

PHP
$quality = $isScreenshot ? 93 : 82;
imagewebp($canvas, $destination, $quality);

اعداد را روی تصویرهای واقعی خودتان تنظیم کنید؛ مهم این است که دو سطح داشته باشید، نه اینکه دقیقاً همین اعداد باشند.

نسخه‌های چندگانه و srcset#

برای هر تصویر چند عرض بسازید تا موبایل فایل کوچک بگیرد:

PHP
$widths = [400, 800, 1200, 1600];

foreach ($widths as $width) {
    if ($width >= $sourceWidth) {
        continue; // بزرگ‌نمایی هیچ‌وقت
    }

    // ساخت و ذخیره‌ی نسخه
}

بزرگ‌نمایی نکنید. ساختن نسخه‌ی ۱۶۰۰ از منبع ۸۰۰ فقط حجم اضافه می‌سازد بدون هیچ سود کیفی.

تله‌ای که خوردیم#

قاعده‌ی بالا یک عارضه دارد که تا مدتی ندیدیمش. فرض کنید منبع ۷۸۴ پیکسل عرض دارد:

  • نسخه‌های WebP: فقط ۴۰۰ ساخته می‌شود، چون ۸۰۰ از منبع بزرگ‌تر است.
  • نسخه‌ی پشتیبان JPEG: در عرض بومی یعنی ۷۸۴ ساخته می‌شود.

نتیجه؟ مرورگر مدرن که WebP می‌فهمد تصویر ۴۰۰ می‌گیرد و مرورگر قدیمی تصویر ۷۸۴. یعنی هرچه مرورگر بهتر، تصویر تارتر.

راه‌حل: وقتی منبع از بزرگ‌ترین عرض فهرست باریک‌تر است، عرض بومی را هم به نسخه‌های WebP اضافه کنید.

در سمت HTML#

HTML
<picture>
  <source type="image/webp" srcset="a-400.webp 400w, a-800.webp 800w" sizes="(max-width: 48rem) 100vw, 48rem">
  <img src="a.jpg" width="1200" height="800" alt="..." loading="lazy" decoding="async">
</picture>

‏width و height را همیشه بنویسید. بدون آن‌ها مرورگر ارتفاع را نمی‌داند و صفحه هنگام بارگذاری تصویر می‌پرد. این یکی از سه معیار اصلی تجربه‌ی صفحه در گوگل است و اصلاحش رایگان است.

برای تصویر اول صفحه loading="lazy" نگذارید؛ آن معمولاً بزرگ‌ترین عنصر قابل مشاهده است و تنبل‌بارگذاری کردنش سرعت را بدتر می‌کند.

پشتیبانی از ورودی AVIF#

اگر کاربر AVIF آپلود کند و شما فقط JPEG و PNG را بشناسید، خطای مبهم می‌گیرد. بهتر است ورودی را بپذیرید حتی اگر خروجی AVIF نمی‌سازید:

PHP
$image = match ($mime) {
    'image/jpeg' => imagecreatefromjpeg($path),
    'image/png' => imagecreatefrompng($path),
    'image/webp' => imagecreatefromwebp($path),
    'image/avif' => function_exists('imagecreatefromavif')
        ? imagecreatefromavif($path)
        : throw new RuntimeException('این سرور از ورودی AVIF پشتیبانی نمی‌کند.'),
    default => throw new RuntimeException('قالب تصویر پشتیبانی نمی‌شود.'),
};

خروجی AVIF را عمداً نمی‌سازیم: روی هاست اشتراکی پردازنده گران است و سودش نسبت به WebP حاشیه‌ای.

بهتر از رد کردن: کوچک کردن#

اولین نسخه‌ی خط لوله‌ی ما تصویرهای بزرگ‌تر از حد را رد می‌کرد. تجربه‌ی کاربری بدی بود: مدیر سایت یک عکس معمولی موبایل آپلود می‌کرد و پیام خطا می‌گرفت.

نسخه‌ی بهتر: اگر ابعاد از سقف رد شد، خودکار کوچکش کن و ادامه بده. فقط وقتی رد کن که حتی بعد از کوچک‌کردن هم در حافظه جا نمی‌شود.

اعتبارسنجی فایل: به پسوند اعتماد نکنید#

قبل از هر پردازشی، فایل ورودی را جدی بررسی کنید. پسوند فایل و حتی نوع اعلام‌شده از سمت مرورگر هر دو قابل جعل‌اند.

PHP
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->file($path);

$allowed = ['image/jpeg', 'image/png', 'image/webp', 'image/avif'];

if (! in_array($mime, $allowed, true)) {
    throw new RuntimeException('قالب فایل مجاز نیست.');
}

و مهم‌تر: نام فایل نهایی را خودتان بسازید، هیچ‌وقت از نام ارسالی کاربر مستقیم استفاده نکنید. نام کاربر می‌تواند کاراکتر مسیر داشته باشد یا با پسوند دوگانه سعی کند فایل اجرایی بسازد.

الگویی که ما استفاده می‌کنیم: نامک فارسی از عنوان تصویر، به‌علاوه‌ی یک هش کوتاه تصادفی برای یکتایی، به‌علاوه‌ی پسوندی که خودمان بر اساس قالب خروجی تعیین کرده‌ایم.

پوشه‌ی مقصد را هم بر اساس سال و ماه تقسیم کنید:

Code
storage/app/public/media/1405/05/تصویر-نمونه-a1b2c3-1200.webp

دلیلش کارایی فایل‌سیستم است: یک پوشه با ده‌ها هزار فایل، هر عملیات فهرست‌کردن را کند می‌کند.

بررسی سمت مرورگر: تجربه‌ی کاربری، نه امنیت#

اعتبارسنجی سمت سرور اجباری است. ولی اگر فقط همان را داشته باشید، کاربر باید یک فایل ده مگابایتی را کامل آپلود کند تا بفهمد قبول نمی‌شود. روی اینترنت ایران این یعنی چند دقیقه انتظار برای یک پیام خطا.

با چند خط جاوااسکریپت خام می‌شود قبل از ارسال بررسی کرد:

JavaScript
input.addEventListener('change', () => {
  const file = input.files[0];
  if (!file) return;

  if (file.size > 8 * 1024 * 1024) {
    show('حجم فایل بیشتر از هشت مگابایت است.');
    input.value = '';
    return;
  }

  const img = new Image();
  img.onload = () => {
    if (img.naturalWidth * img.naturalHeight > 40_000_000) {
      show('ابعاد تصویر خیلی بزرگ است.');
      input.value = '';
    }
    URL.revokeObjectURL(img.src);
  };
  img.src = URL.createObjectURL(file);
});

و یک نوار پیشرفت واقعی هم اضافه کنید. برای آپلود چندمگابایتی روی اتصال کند، نبودِ بازخورد یعنی کاربر فکر می‌کند سایت هنگ کرده و دکمه را دوباره می‌زند.

این بررسی‌ها جایگزین اعتبارسنجی سرور نیستند. هر چیزی که در مرورگر اجرا می‌شود قابل دور زدن است.

سراغ چه چیزی نروید#

چند کار هست که با GD ممکن‌اند ولی روی هاست اشتراکی نباید بروید سراغشان:

  • پردازش هم‌زمان چند تصویر بزرگ در یک درخواست. حافظه‌ی هر تصویر آزاد نمی‌شود مگر اینکه صریح imagedestroy کنید، و حتی آن‌وقت هم پراکندگی حافظه می‌تواند مشکل‌ساز شود. تصویرها را یکی‌یکی پردازش کنید.
  • تولید نسخه‌ها در لحظه‌ی درخواست. یعنی هر بازدید یک پردازش تصویر. نسخه‌ها را موقع آپلود بسازید و بعد فقط فایل ثابت سرو کنید.
  • کار روی تصویرهای خیلی بزرگ. یک سقف منطقی بگذارید و بالاتر از آن را کوچک کنید.

هر سه‌ی این‌ها به یک اصل برمی‌گردند: روی هاست اشتراکی، کار سنگین باید یک بار انجام شود و نتیجه‌اش ذخیره شود — نه اینکه به ازای هر بازدید تکرار شود.

یک ابزار که حتماً بسازید#

نسخه‌سازی تصویر با گذر زمان عوض می‌شود: عرض جدید اضافه می‌کنید، کیفیت را تغییر می‌دهید. یک دستور که همه‌ی نسخه‌ها را از منبع دوباره بسازد بدون نیاز به آپلود مجدد، ارزشش را دارد:

Bash
php84 artisan media:reprocess --id=42
php84 artisan media:reprocess --all

فایل منبع را همیشه نگه دارید. بدون منبع، هر تغییری در سیاست نسخه‌سازی یعنی آپلود دوباره‌ی همه چیز.

اندازه‌گیری: قبل و بعد را واقعاً ببینید#

بدون اندازه‌گیری، هر تصمیمی درباره‌ی کیفیت حدس است. بعد از هر تغییر در تنظیمات، حجم واقعی خروجی‌ها را ببینید:

Bash
find ~/app/storage/app/public/media -name '*.webp' -printf '%s %p\n' | sort -rn | head -20

بیست فایل بزرگ را نشان می‌دهد. اگر یکی‌شان خیلی از بقیه بزرگ‌تر است، معمولاً یعنی یک تصویر با ابعاد غیرعادی یا با کیفیت اشتباه پردازش شده.

و مجموع فضای اشغال‌شده:

Bash
du -sh ~/app/storage/app/public/media

روی هاست اشتراکی فضا محدود است و نسخه‌های چندگانه، فضا را چند برابر می‌کنند. اگر برای هر تصویر چهار عرض WebP به‌علاوه‌ی یک JPEG پشتیبان می‌سازید، هر آپلود پنج فایل است.

تصمیمی که آگاهانه گرفتیم: خروجی AVIF نمی‌سازیم#

قالب AVIF در حجم برابر کیفیت بهتری از WebP می‌دهد. پس چرا نمی‌سازیمش؟

چون هزینه‌ی پردازنده‌اش روی هاست اشتراکی زیاد است و سود اضافه‌اش نسبت به WebP حاشیه‌ای. WebP امروز تقریباً همه‌جا پشتیبانی می‌شود و اختلاف حجمش با AVIF برای تصویرهای معمولی وب آن‌قدر نیست که ارزش زمان پردازش و پیچیدگی اضافه را داشته باشد.

ولی ورودی AVIF را می‌پذیریم، چون گوشی‌ها و ابزارهای امروزی به‌طور فزاینده‌ای با این قالب خروجی می‌دهند و رد کردنش فقط کاربر را سردرگم می‌کند.

این الگوی تصمیم‌گیری را توصیه می‌کنیم: در ورودی سخاوتمند باشید، در خروجی محافظه‌کار. هر قالبی که می‌توانید بخوانید بخوانید، ولی فقط قالب‌هایی تولید کنید که هزینه‌شان را توجیه می‌کنند.

خلاصه#

  • قبل از باز کردن تصویر، حافظه‌ی لازم را تخمین بزنید. getimagesize ارزان است.
  • چرخش EXIF را خودتان اعمال کنید.
  • برای شفافیت imagealphablending و imagesavealpha را یادتان نرود.
  • کیفیت را بر اساس نوع تصویر انتخاب کنید، نه یک عدد ثابت برای همه.
  • بزرگ‌نمایی نکنید، ولی حواستان به تله‌ی عرض بومی باشد.
  • width و height را در HTML بنویسید.