在開發圖片分享平台、電商系統或內容管理系統時,保護圖片版權、宣傳品牌標識都是必要需求。若每張圖片都靠人工在 Photoshop 中添加水印,不僅效率低下,還難以維持一致性。PHP 內建的 GD 庫提供了強大的圖像處理能力,讓我們能在上傳、生成縮圖或輸出時自動合成水印,實現「上傳即保護」的工作流。
執行任何圖像操作前,必須確認 PHP 已載入 GD 擴展。打開 php.ini,找到以下行並移除前面的分號:
;extension=php_gd2.dll
→ extension=php_gd2.dll
同時確認 extension_dir 指向正確的擴展目錄。重啟 Apache 或 PHP-FPM 後,透過 phpinfo() 或 extension_loaded('gd') 驗證是否生效。
一個彈性的水印類別通常需要以下配置:
<?php
class ImageWatermarker {
private $config = [
'allowed_types' => ['image/jpeg','image/png','image/gif','image/webp'],
'max_size' => 2_000_000,
'dest_dir' => __DIR__ . '/uploads/',
'watermark' => true,
'wm_type' => 'text', // 'text' | 'image'
'wm_position' => 5, // 1-9 九宮格
'wm_text' => '© MySite.com',
'wm_font' => __DIR__ . '/fonts/noto.ttc',
'wm_font_size' => 20,
'wm_color' => [255,255,255,100], // RGBA
'wm_image' => __DIR__ . '/assets/logo.png',
'wm_opacity' => 50, // 0-100
'make_thumb' => true,
'thumb_ratio' => 0.5,
];
public function __construct(array $overrides = []) {
$this->config = array_merge($this->config, $overrides);
if (!is_dir($this->config['dest_dir'])) {
mkdir($this->config['dest_dir'], 0755, true);
}
}
public function handleUpload(array $file): array {
// 1. 基礎驗證
if (!isset($file['tmp_name']) || !is_uploaded_file($file['tmp_name'])) {
throw new RuntimeException('無效的上傳檔案');
}
if ($file['size'] > $this->config['max_size']) {
throw new RuntimeException('檔案超過大小限制');
}
if (!in_array($file['type'], $this->config['allowed_types'], true)) {
throw new RuntimeException('不支援的檔案類型:' . $file['type']);
}
// 2. 取得圖片資訊
[$src_w, $src_h, $type] = getimagesize($file['tmp_name']);
$ext = image_type_to_extension($type, false); // jpeg, png, gif, webp
// 3. 建立唯一檔名
$filename = bin2hex(random_bytes(8)) . '.' . $ext;
$dest_path = $this->config['dest_dir'] . $filename;
// 4. 依類型建立圖片資源
$src = $this->createImageFromFile($file['tmp_name'], $type);
if (!$src) throw new RuntimeException('無法讀取圖片資源');
// 5. 若啟用水印 → 合成
if ($this->config['watermark']) {
$this->applyWatermark($src, $src_w, $src_h);
}
// 6. 輸出覆蓋原檔(或另存新檔)
$this->saveImage($src, $dest_path, $type);
// 7. 產生縮圖
$thumb_path = null;
if ($this->config['make_thumb']) {
$thumb_path = $this->makeThumbnail($src, $src_w, $src_h, $dest_path, $type);
}
imagedestroy($src);
return [
'path' => $dest_path,
'url' => '/uploads/' . $filename,
'width' => $src_w,
'height' => $src_h,
'size' => $file['size'],
'thumb_url' => $thumb_path ? '/uploads/' . basename($thumb_path) : null,
];
}
private function createImageFromFile(string $path, int $type): ?\GdImage {
return match ($type) {
IMAGETYPE_JPEG => imagecreatefromjpeg($path),
IMAGETYPE_PNG => imagecreatefrompng($path),
IMAGETYPE_GIF => imagecreatefromgif($path),
IMAGETYPE_WEBP => imagecreatefromwebp($path),
default => null,
};
}
private function applyWatermark(\GdImage &$canvas, int $w, int $h): void {
if ($this->config['wm_type'] === 'text') {
$this->drawTextWatermark($canvas, $w, $h);
} elseif ($this->config['wm_type'] === 'image' && file_exists($this->config['wm_image'])) {
$this->drawImageWatermark($canvas, $w, $h);
}
}
private function drawTextWatermark(\GdImage $im, int $w, int $h): void {
[$r,$g,$b,$a] = $this->config['wm_color'];
$color = imagecolorallocatealpha($im, $r, $g, $b, 127 - (int)($a * 1.27)); // 0-127
$box = imagettfbbox($this->config['wm_font_size'], 0, $this->config['wm_font'], $this->config['wm_text']);
$tw = $box[2] - $box[0];
$th = $box[1] - $box[7];
[$x, $y] = $this->calcPosition($w, $h, $tw, $th);
imagettftext($im, $this->config['wm_font_size'], 0, $x, $y, $color, $this->config['wm_font'], $this->config['wm_text']);
}
private function drawImageWatermark(\GdImage $im, int $w, int $h): void {
$wm = $this->createImageFromFile($this->config['wm_image'], exif_imagetype($this->config['wm_image']));
if (!$wm) return;
$wm_w = imagesx($wm);
$wm_h = imagesy($wm);
[$x, $y] = $this->calcPosition($w, $h, $wm_w, $wm_h);
imagecopymerge($im, $wm, $x, $y, 0, 0, $wm_w, $wm_h, $this->config['wm_opacity']);
imagedestroy($wm);
}
private function calcPosition(int $W, int $H, int $w, int $h): array {
$pad = 10;
$pos = $this->config['wm_position'];
$x = match (true) {
in_array($pos, [1,4,7]) => $pad,
in_array($pos, [3,6,9]) => $W - $w - $pad,
default => ($W - $w) / 2,
};
$y = match (true) {
in_array($pos, [1,2,3]) => $pad + $h,
in_array($pos, [7,8,9]) => $H - $pad,
default => ($H + $h) / 2,
};
return [(int)$x, (int)$y];
}
private function saveImage(\GdImage $im, string $path, int $type): void {
switch ($type) {
case IMAGETYPE_JPEG: imagejpeg($im, $path, 90); break;
case IMAGETYPE_PNG: imagepng($im, $path, 6); break;
case IMAGETYPE_GIF: imagegif($im, $path); break;
case IMAGETYPE_WEBP: imagewebp($im, $path, 90); break;
}
}
private function makeThumbnail(\GdImage $src, int $w, int $h, string $orig_path, int $type): ?string {
$new_w = (int)($w * $this->config['thumb_ratio']);
$new_h = (int)($h * $this->config['thumb_ratio']);
$thumb = imagecreatetruecolor($new_w, $new_h);
// 保留透明度
if (in_array($type, [IMAGETYPE_PNG, IMAGETYPE_GIF, IMAGETYPE_WEBP], true)) {
imagealphablending($thumb, false);
imagesavealpha($thumb, true);
}
imagecopyresampled($thumb, $src, 0, 0, 0, 0, $new_w, $new_h, $w, $h);
$thumb_path = preg_replace('/\.[^.]+$/', '_thumb.$0', $orig_path);
$this->saveImage($thumb, $thumb_path, $type);
imagedestroy($thumb);
return $thumb_path;
}
}
// ── 使用範例 ──
$wm = new ImageWatermarker([
'wm_text' => '© ' . date('Y') . ' MyBrand.com',
'wm_position' => 9, // 右下角
]);
try {
$result = $wm->handleUpload($_FILES['image']);
echo json_encode(['success' => true, 'data' => $result]);
} catch (Throwable $e) {
http_response_code(400);
echo json_encode(['success' => false, 'message' => $e->getMessage()]);
}
PNG / WebP 含 Alpha 通道時,務必在建立畫布後啟用 imagesavealpha($im, true) 並關閉 imagealphablending($im, false),否則透明區域會變黑。
imagettftext 需要 TrueType 字體檔(.ttf / .ttc)。中文建議使用 Noto Sans TC、思源黑體等開源字體,並以絕對路徑載入。
大圖(如 4000×3000)處理時可能觸發 memory_limit。可在處理前暫時調大:ini_set('memory_limit', '256M'),或改用 ImageMagick(imagick 擴展)以串流方式處理。
location ~ \.php$ { deny all; })finfo_file 二進位偵測,不要只信 $_FILES['type']透過封裝成可複用的 ImageWatermarker 類別,我們不只解決「加水印」這個單一需求,更建立了可擴充的圖片處理管線:驗證 → 轉檔 → 水印 → 縮圖 → 儲存 → 回傳結構化資訊。未來若需加入「壓縮優化」、「EXIF 剝離」、「雲端同步」等步驟,只需在管線中插入新方法即可,符合單一職責與開放封閉原則。