圖片浮水印是版權保護、品牌曝光的常用手段。本文提供生產級實作:支援文字/圖片浮水印、9 宮格位置、透明度、旋轉、批次處理與效能優化。
<?php
class Watermarker {
private array $config = [
'allowedTypes' => ['image/jpeg', 'image/png', 'image/gif', 'image/webp'],
'maxSize' => 10 * 1024 * 1024, // 10MB
'jpegQuality' => 90,
'pngCompression' => 6,
];
public function __construct(array $options = []) {
$this->config = array_merge($this->config, $options);
}
// 主入口:對單一圖片加浮水印
public function watermark(string $sourcePath, string $destPath, array $options = []): bool {
$opts = array_merge([
'type' => 'text', // 'text' | 'image'
'content' => '© Watermark', // 文字內容或浮水印圖片路徑
'position' => 'bottom-right', // 9宮格位置
'opacity' => 50, // 0-100
'fontSize' => 20,
'fontColor' => '#FFFFFF',
'fontFile' => null, // TTF 字體路徑
'rotation' => 0, // 旋轉角度
'margin' => 10, // 邊距
], $options);
// 1. 驗證來源檔案
if (!file_exists($sourcePath)) throw new Exception('來源檔案不存在');
$info = getimagesize($sourcePath);
if (!$info || !in_array($info['mime'], $this->config['allowedTypes'])) {
throw new Exception('不支援的圖片格式');
}
if (filesize($sourcePath) > $this->config['maxSize']) {
throw new Exception('檔案過大');
}
// 2. 建立來源圖片資源
$source = $this->createImageResource($sourcePath, $info['mime']);
if (!$source) throw new Exception('無法載入圖片');
// 3. 建立浮水印層
$watermark = $this->createWatermarkLayer($source, $opts);
if (!$watermark) throw new Exception('建立浮水印失敗');
// 4. 計算位置並合成
$this->applyWatermark($source, $watermark, $opts);
// 5. 輸出
return $this->saveImage($source, $destPath, $info['mime']);
}
// 批次處理
public function batchWatermark(string $srcDir, string $destDir, array $options = []): array {
$results = [];
$files = glob($srcDir . '/*.{jpg,jpeg,png,gif,webp}', GLOB_BRACE);
foreach ($files as $file) {
$dest = $destDir . '/' . basename($file);
try {
$success = $this->watermark($file, $dest, $options);
$results[] = ['file' => $file, 'success' => $success];
} catch (Exception $e) {
$results[] = ['file' => $file, 'success' => false, 'error' => $e->getMessage()];
}
}
return $results;
}
private function createImageResource(string $path, string $mime): ?resource {
return match ($mime) {
'image/jpeg' => imagecreatefromjpeg($path),
'image/png' => imagecreatefrompng($path),
'image/gif' => imagecreatefromgif($path),
'image/webp' => imagecreatefromwebp($path), // PHP 8.1+
default => null,
};
}
private function createWatermarkLayer(resource $source, array $opts): ?resource {
$srcW = imagesx($source);
$srcH = imagesy($source);
// 建立透明畫布
$layer = imagecreatetruecolor($srcW, $srcH);
imagealphablending($layer, false);
imagesavealpha($layer, true);
$transparent = imagecolorallocatealpha($layer, 0, 0, 0, 127);
imagefill($layer, 0, 0, $transparent);
if ($opts['type'] === 'text') {
$this->drawTextWatermark($layer, $opts);
} elseif ($opts['type'] === 'image') {
$this->drawImageWatermark($layer, $opts);
}
// 旋轉 (需 PHP 8.0+ imagerotate 支援 alpha)
if ($opts['rotation'] !== 0) {
$layer = imagerotate($layer, $opts['rotation'], $transparent);
}
return $layer;
}
private function drawTextWatermark(resource $layer, array $opts): void {
$color = $this->hexToRgba($opts['fontColor'], $opts['opacity']);
$fontFile = $opts['fontFile'] ?? __DIR__ . '/fonts/NotoSansTC-Regular.otf';
if (!file_exists($fontFile)) {
// 回退內建字體 (不支援中文)
imagestring($layer, 5, 10, 10, $opts['content'], $color);
return;
}
// 計算文字範圍
$bbox = imagettfbbox($opts['fontSize'], 0, $fontFile, $opts['content']);
$textW = $bbox[2] - $bbox[0];
$textH = $bbox[1] - $bbox[7];
// 文字繪製座標 (左下基線)
$x = ($opts['margin'] < 0) ? (imagesx($layer) + $opts['margin'] - $textW) : $opts['margin'];
$y = ($opts['margin'] < 0) ? (imagesy($layer) + $opts['margin'] + $textH) : ($opts['margin'] + $textH);
imagettftext($layer, $opts['fontSize'], 0, $x, $y, $color, $fontFile, $opts['content']);
}
private function drawImageWatermark(resource $layer, array $opts): void {
$wmPath = $opts['content'];
if (!file_exists($wmPath)) return;
$wmInfo = getimagesize($wmPath);
$wmResource = $this->createImageResource($wmPath, $wmInfo['mime']);
if (!$wmResource) return;
// 調整浮水印大小 (最大佔原圖 30%)
$maxW = imagesx($layer) * 0.3;
$maxH = imagesy($layer) * 0.3;
$wmW = imagesx($wmResource);
$wmH = imagesy($wmResource);
$scale = min($maxW / $wmW, $maxH / $wmH, 1);
if ($scale < 1) {
$newW = (int)($wmW * $scale);
$newH = (int)($wmH * $scale);
$resized = imagecreatetruecolor($newW, $newH);
imagealphablending($resized, false);
imagesavealpha($resized, true);
imagecopyresampled($resized, $wmResource, 0, 0, 0, 0, $newW, $newH, $wmW, $wmH);
imagedestroy($wmResource);
$wmResource = $resized;
$wmW = $newW;
$wmH = $newH;
}
// 置中繪製 (位置稍後由 applyWatermark 處理)
imagecopy($layer, $wmResource, 0, 0, 0, 0, $wmW, $wmH);
imagedestroy($wmResource);
}
private function applyWatermark(resource $source, resource $watermark, array $opts): void {
$srcW = imagesx($source);
$srcH = imagesy($source);
$wmW = imagesx($watermark);
$wmH = imagesy($watermark);
$positions = [
'top-left' => [$opts['margin'], $opts['margin']],
'top-center' => [($srcW - $wmW) / 2, $opts['margin']],
'top-right' => [$srcW - $wmW - $opts['margin'], $opts['margin']],
'middle-left' => [$opts['margin'], ($srcH - $wmH) / 2],
'center' => [($srcW - $wmW) / 2, ($srcH - $wmH) / 2],
'middle-right' => [$srcW - $wmW - $opts['margin'], ($srcH - $wmH) / 2],
'bottom-left' => [$opts['margin'], $srcH - $wmH - $opts['margin']],
'bottom-center' => [($srcW - $wmW) / 2, $srcH - $wmH - $opts['margin']],
'bottom-right' => [$srcW - $wmW - $opts['margin'], $srcH - $wmH - $opts['margin']],
];
[$x, $y] = $positions[$opts['position']] ?? $positions['bottom-right'];
$x = max(0, (int)$x);
$y = max(0, (int)$y);
// 透明度混合
imagecopymerge($source, $watermark, $x, $y, 0, 0, $wmW, $wmH, $opts['opacity']);
}
private function saveImage(resource $image, string $path, string $mime): bool {
return match ($mime) {
'image/jpeg' => imagejpeg($image, $path, $this->config['jpegQuality']),
'image/png' => imagepng($image, $path, $this->config['pngCompression']),
'image/gif' => imagegif($image, $path),
'image/webp' => imagewebp($image, $path, $this->config['jpegQuality']),
default => false,
};
}
private function hexToRgba(string $hex, int $opacity): int {
$hex = ltrim($hex, '#');
$r = hexdec(substr($hex, 0, 2));
$g = hexdec(substr($hex, 2, 2));
$b = hexdec(substr($hex, 4, 2));
$alpha = (int)(127 * (100 - $opacity) / 100);
return imagecolorallocatealpha(null, $r, $g, $b, $alpha);
}
}
?>
<?php
$watermarker = new Watermarker([
'jpegQuality' => 95,
'maxSize' => 20 * 1024 * 1024,
]);
// 1. 文字浮水印
$watermarker->watermark('photo.jpg', 'photo_wm.jpg', [
'type' => 'text',
'content' => '© 2024 MySite.com',
'position' => 'bottom-right',
'opacity' => 40,
'fontSize' => 24,
'fontColor' => '#FFFFFF',
'fontFile' => '/fonts/NotoSansTC-Bold.otf',
'rotation' => -15,
]);
// 2. 圖片浮水印 (Logo)
$watermarker->watermark('photo.jpg', 'photo_logo.jpg', [
'type' => 'image',
'content' => '/assets/logo.png',
'position' => 'center',
'opacity' => 30,
]);
// 3. 批次處理
$results = $watermarker->batchWatermark('/uploads/originals', '/uploads/watermarked', [
'type' => 'text',
'content' => 'Internal Use Only',
'position' => 'center',
'opacity' => 20,
'fontSize' => 36,
'fontColor' => '#FF0000',
'rotation' => 45,
]);
?>
<?php
// upload.php
$watermarker = new Watermarker();
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['image'])) {
$file = $_FILES['image'];
$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
$allowed = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
if (!in_array($ext, $allowed)) {
die('不允許的格式');
}
$tempName = $file['tmp_name'];
$finalName = 'img_' . bin2hex(random_bytes(8)) . '.' . $ext;
$finalPath = '/uploads/' . $finalName;
$wmPath = '/uploads/wm_' . $finalName;
// 移動上傳檔
move_uploaded_file($tempName, $finalPath);
// 加浮水印
try {
$watermarker->watermark($finalPath, $wmPath, [
'type' => 'text',
'content' => '© MySite.com',
'position' => 'bottom-right',
'opacity' => 50,
]);
echo json_encode(['success' => true, 'original' => $finalPath, 'watermarked' => $wmPath]);
} catch (Exception $e) {
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
}
}
?>
| 場景 | 優化方案 |
|---|---|
| 高併發上傳 | 隊列異步處理 + Worker 進程 |
| 大量批次 | Imagick (C 擴展) 比 GD 快 3-5 倍 |
| 重複浮水印 | 預先渲染浮水印層快取,合成時直接 copy |
| 超大圖片 | 分塊處理或使用 VIPS (libvips) |
浮水印核心在於「透明圖層疊加 + 位置計算 + 格式保真」,GD 滿足大多數需求,高效能場景建議遷移至 Imagick 或 VIPS。