PluginProbe
WPWaterMark 轻水印插件 / trunk
WPWaterMark 轻水印插件 vtrunk
5.2.4 5.2.2 5.2.1 5.1.7 5.1.6 trunk 4.3 5.0.0 5.0.1 5.1.2 5.1.3 5.1.4 5.1.5
wpwatermark / WaterMarkHandler.php

WaterMarkHandler.php in WPWaterMark 轻水印插件 trunk, at WaterMarkHandler.php

1,073 lines 38.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * WaterMark Handler Class
4 *
5 * Handles all watermark related operations with improved organization and caching
6 */
7 class WaterMarkHandler {
8 /** @var string */
9 private $cache_dir;
10
11 /** @var string */
12 private $font_dir;
13
14 /** @var array */
15 private $options;
16
17 /**
18 * 支持添加水印的 MIME 类型
19 *
20 * @return string[]
21 */
22 public static function getSupportedMimeTypes(): array {
23 $mimes = ['image/jpeg', 'image/png', 'image/gif'];
24 if (function_exists('imagecreatefromwebp') && function_exists('imagewebp')) {
25 $mimes[] = 'image/webp';
26 }
27 return $mimes;
28 }
29
30 /**
31 * 支持添加水印的扩展名(不带点)
32 *
33 * @return string[]
34 */
35 public static function getSupportedExtensions(): array {
36 $extensions = ['jpg', 'jpeg', 'png', 'gif'];
37 if (function_exists('imagecreatefromwebp') && function_exists('imagewebp')) {
38 $extensions[] = 'webp';
39 }
40 return $extensions;
41 }
42
43 /**
44 * Constructor
45 *
46 * @param array $options Watermark options
47 */
48 public function __construct(array $options) {
49 $this->options = $options;
50 $this->cache_dir = plugin_dir_path(__FILE__) . 'cache/';
51 $this->font_dir = plugin_dir_path(__FILE__) . 'fonts/';
52
53 // 缓存默认�
54 �闭,不再自动创建缓存目录
55 if ($this->isCacheEnabled() && !file_exists($this->cache_dir)) {
56 wp_mkdir_p($this->cache_dir);
57 }
58 }
59
60 /**
61 * Generate cache key for watermark options
62 *
63 * @param string $img_url
64 * @param array $options
65 * @return string
66 */
67 private function generateCacheKey(string $img_url, array $options): string {
68 return md5($img_url . serialize($options));
69 }
70
71 /**
72 * 是否启用水印缓存(默认�
73 �闭,避�
74 �长期占用磁盘)
75 */
76 private function isCacheEnabled(): bool {
77 return false;
78 }
79
80 /**
81 * 九宫格位置键名列表(与 calculatePosition 的 case 一致)
82 *
83 * @return string[]
84 */
85 private static function getGridPositionKeys(): array {
86 return [
87 'top-left', 'top-center', 'top-right',
88 'middle-left', 'middle-center', 'middle-right',
89 'bottom-left', 'bottom-center', 'bottom-right',
90 ];
91 }
92
93 /**
94 * 若设置为 random,则每次处理在九宫格中随机选一格
95 */
96 private function resolveGridPosition(string $position): string {
97 if ($position === 'random') {
98 $grid = self::getGridPositionKeys();
99 return $grid[wp_rand(0, count($grid) - 1)];
100 }
101 return $position;
102 }
103
104 /**
105 * 从本次调用的 options 与默认�
106 �置得到原始位置字符串(可能为 random)
107 */
108 private function getRawWatermarkPosition(array $options): string {
109 if (isset($options['watermark_position']) && $options['watermark_position'] !== '') {
110 return (string) $options['watermark_position'];
111 }
112 if (isset($options['position']) && $options['position'] !== '') {
113 return (string) $options['position'];
114 }
115 return (string) $this->options['watermark_position'];
116 }
117
118 /**
119 * Check if cached version exists
120 *
121 * @param string $cache_key
122 * @return string|false
123 */
124 private function getCachedImage(string $cache_key) {
125 $cache_extensions = ['jpg', 'png', 'gif', 'webp'];
126 foreach ($cache_extensions as $ext) {
127 $cache_file = $this->cache_dir . $cache_key . '.' . $ext;
128 if (file_exists($cache_file) && (time() - filemtime($cache_file) < 3600)) {
129 return $cache_file;
130 }
131 }
132 return false;
133 }
134
135 /**
136 * Create text watermark with improved error handling and caching
137 *
138 * @param string $img_url
139 * @param string $new_img_url
140 * @param string $text
141 * @param array $options
142 * @return bool
143 */
144 public function createTextWatermark(string $img_url, string $new_img_url, string $text, array $options = []): bool {
145 try {
146 $merged = array_merge($this->options, $options);
147 $raw_position = $this->getRawWatermarkPosition($options);
148 $use_cache = ($raw_position !== 'random') && $this->isCacheEnabled();
149
150 if ($use_cache) {
151 $cache_key = $this->generateCacheKey($img_url, $merged);
152 $cached_file = $this->getCachedImage($cache_key);
153 if ($cached_file) {
154 copy($cached_file, $new_img_url);
155 return true;
156 }
157 }
158
159 // Validate image
160 $img_size = getimagesize($img_url);
161 if (empty($img_size)) {
162 throw new Exception('Invalid image file');
163 }
164
165 // Validate dimensions
166 if ($img_size[0] < $this->options['watermark_min_width'] ||
167 $img_size[1] < $this->options['watermark_min_height']) {
168 throw new Exception('Image dimensions too small for watermark');
169 }
170
171 // Create image resource
172 $im = $this->createImageResource($img_url, $img_size['mime']);
173 if (!$im) {
174 throw new Exception('Failed to create image resource');
175 }
176
177 // Apply text watermark
178 $text_color = $this->parseColor($options['text_color'] ?? $this->options['text_color']);
179 $font_file = $this->font_dir . ($options['text_font'] ?? $this->options['text_font']);
180
181 if (!file_exists($font_file)) {
182 throw new Exception('Font file not found');
183 }
184
185 $opacity = intval($options['watermark_diaphaneity'] ?? $this->options['watermark_diaphaneity']);
186 $opacity = min(100, max(0, $opacity));
187 $font_size = intval($options['text_size'] ?? $this->options['text_size']);
188 $font_angle = intval($options['text_angle'] ?? $this->options['text_angle']);
189
190 $position = $this->calculatePosition(
191 $this->resolveGridPosition($raw_position),
192 $img_size[0],
193 $img_size[1],
194 $text,
195 0,
196 0,
197 $font_size,
198 $font_angle,
199 $font_file
200 );
201
202 $this->renderTextLayer(
203 $im,
204 $img_size[0],
205 $img_size[1],
206 $text,
207 $font_file,
208 $font_size,
209 $font_angle,
210 $position['x'],
211 $position['y'],
212 $text_color,
213 $opacity
214 );
215
216 if ($img_size['mime'] === 'image/png' || $img_size['mime'] === 'image/webp') {
217 imagealphablending($im, false);
218 imagesavealpha($im, true);
219 }
220
221 // Save image
222 if (!$this->saveImage($im, $new_img_url, $img_size['mime'], $img_url)) {
223 throw new Exception('Failed to save watermarked image');
224 }
225
226 // Cache result(随机位置不使用缓存,避�
227 �多次上传被同一随机结果锁死)
228 if ($use_cache) {
229 $cache_ext = 'jpg';
230 if ($img_size['mime'] === 'image/png') {
231 $cache_ext = 'png';
232 } elseif ($img_size['mime'] === 'image/gif') {
233 $cache_ext = 'gif';
234 } elseif ($img_size['mime'] === 'image/webp') {
235 $cache_ext = 'webp';
236 }
237 copy($new_img_url, $this->cache_dir . $cache_key . '.' . $cache_ext);
238 }
239
240 imagedestroy($im);
241 return true;
242
243 } catch (Exception $e) {
244 error_log('WaterMark Error: ' . $e->getMessage());
245 return false;
246 }
247 }
248
249 /**
250 * Create image watermark with improved error handling and caching
251 *
252 * @param string $img_url
253 * @param string $watermark_url
254 * @param string $new_img_url
255 * @param array $options
256 * @return bool
257 */
258 public function createImageWatermark($img_url, $watermark_url, $new_img_url, $options = []) {
259 try {
260 $merged = array_merge($this->options, $options);
261 $raw_position = $this->getRawWatermarkPosition($options);
262 $use_cache = ($raw_position !== 'random') && $this->isCacheEnabled();
263
264 if ($use_cache) {
265 $cache_key = $this->generateCacheKey($img_url, $merged);
266 $cached_file = $this->getCachedImage($cache_key);
267 if ($cached_file) {
268 copy($cached_file, $new_img_url);
269 return true;
270 }
271 }
272
273 // Validate image
274 $img_size = getimagesize($img_url);
275 if (empty($img_size)) {
276 throw new Exception('Invalid image file');
277 }
278
279 // Validate dimensions
280 if ($img_size[0] < $this->options['watermark_min_width'] ||
281 $img_size[1] < $this->options['watermark_min_height']) {
282 throw new Exception('Image dimensions too small for watermark');
283 }
284
285 // Create image resource
286 $im = $this->createImageResource($img_url, $img_size['mime']);
287 if (!$im) {
288 throw new Exception('Failed to create image resource');
289 }
290
291 // Load watermark image
292 $watermark_size = getimagesize($watermark_url);
293 if (!$watermark_size) {
294 throw new Exception('Invalid watermark image');
295 }
296
297 $watermark = $this->createImageResource($watermark_url, $watermark_size['mime']);
298 if (!$watermark) {
299 throw new Exception('Failed to create watermark resource');
300 }
301
302 $scale_percent = intval($options['image_watermark_scale'] ?? $this->options['image_watermark_scale'] ?? 100);
303 $scale_percent = min(100, max(1, $scale_percent));
304 if ($scale_percent < 100) {
305 $scaled_width = max(1, intval(round($watermark_size[0] * $scale_percent / 100)));
306 $scaled_height = max(1, intval(round($watermark_size[1] * $scale_percent / 100)));
307 $scaled_watermark = $this->resizeWatermarkResource(
308 $watermark,
309 $watermark_size[0],
310 $watermark_size[1],
311 $scaled_width,
312 $scaled_height
313 );
314 if ($scaled_watermark) {
315 imagedestroy($watermark);
316 $watermark = $scaled_watermark;
317 $watermark_size[0] = $scaled_width;
318 $watermark_size[1] = $scaled_height;
319 }
320 }
321
322 // Calculate position
323 $position = $this->calculatePosition(
324 $this->resolveGridPosition($raw_position),
325 $img_size[0],
326 $img_size[1],
327 '',
328 $watermark_size[0],
329 $watermark_size[1]
330 );
331
332 // 创建临时图像并正确复制原图(JPEG/GIF 不使用透明画布,避�
333 �原图被复制成透明)
334 $temp = $this->createWorkingCanvas($img_size[0], $img_size[1], $img_size['mime']);
335 $this->copyImageOntoCanvas($temp, $im, $img_size[0], $img_size[1], $img_size['mime']);
336
337 // 获取透明度设置
338 $opacity = ($options['watermark_diaphaneity'] ?? $this->options['watermark_diaphaneity']);
339
340 // 如果是PNG水印,保持�
341 �原有透明度
342 if ($watermark_size['mime'] === 'image/png' || $watermark_size['mime'] === 'image/webp') {
343 // 创建水印临时图像
344 $watermark_temp = imagecreatetruecolor($watermark_size[0], $watermark_size[1]);
345 imagealphablending($watermark_temp, false);
346 imagesavealpha($watermark_temp, true);
347
348 // 设置完�
349 �透明背景
350 $transparent = imagecolorallocatealpha($watermark_temp, 0, 0, 0, 127);
351 imagefilledrectangle($watermark_temp, 0, 0, $watermark_size[0], $watermark_size[1], $transparent);
352
353 // 复制水印到临时图像
354 imagecopy($watermark_temp, $watermark, 0, 0, 0, 0, $watermark_size[0], $watermark_size[1]);
355
356 // 应用用户设置的透明度
357 if ($opacity < 100) {
358 // 逐像素调整透明度
359 for ($x = 0; $x < $watermark_size[0]; $x++) {
360 for ($y = 0; $y < $watermark_size[1]; $y++) {
361 $color = imagecolorsforindex($watermark_temp, imagecolorat($watermark_temp, $x, $y));
362 $alpha = 127 - ((127 - $color['alpha']) * $opacity / 100);
363 $new_color = imagecolorallocatealpha(
364 $watermark_temp,
365 $color['red'],
366 $color['green'],
367 $color['blue'],
368 intval($alpha)
369 );
370 imagesetpixel($watermark_temp, $x, $y, $new_color);
371 }
372 }
373 }
374
375 // 合并水印到目标图像
376 imagealphablending($temp, true);
377 imagecopy($temp, $watermark_temp, $position['x'], $position['y'], 0, 0, $watermark_size[0], $watermark_size[1]);
378 imagedestroy($watermark_temp);
379 } else {
380 // 非PNG水印的处理
381 imagealphablending($temp, true);
382 $this->imagecopymerge_alpha(
383 $temp, $watermark,
384 $position['x'], $position['y'],
385 0, 0,
386 $watermark_size[0], $watermark_size[1],
387 $opacity
388 );
389 }
390
391 // 保存最终图像
392 if ($img_size['mime'] === 'image/png' || $img_size['mime'] === 'image/webp') {
393 imagealphablending($temp, false);
394 imagesavealpha($temp, true);
395 }
396 if (!$this->saveImage($temp, $new_img_url, $img_size['mime'], $img_url)) {
397 throw new Exception('Failed to save watermarked image');
398 }
399
400 if ($use_cache) {
401 $cache_ext = 'jpg';
402 if ($img_size['mime'] === 'image/png') {
403 $cache_ext = 'png';
404 } elseif ($img_size['mime'] === 'image/gif') {
405 $cache_ext = 'gif';
406 } elseif ($img_size['mime'] === 'image/webp') {
407 $cache_ext = 'webp';
408 }
409 $cache_file = $this->cache_dir . $cache_key . '.' . $cache_ext;
410 copy($new_img_url, $cache_file);
411 }
412
413 // �
414 理资源
415 imagedestroy($temp);
416 imagedestroy($im);
417 imagedestroy($watermark);
418
419 return true;
420
421 } catch (Exception $e) {
422 error_log('WaterMark Error: ' . $e->getMessage());
423 return false;
424 }
425 }
426
427 /**
428 * Helper function to create image resource
429 *
430 * @param string $img_url
431 * @param string $mime_type
432 * @return resource|false
433 */
434 private function createImageResource(string $img_url, string $mime_type) {
435 $img_size = @getimagesize($img_url);
436 $width = is_array($img_size) ? (int) $img_size[0] : 0;
437 $height = is_array($img_size) ? (int) $img_size[1] : 0;
438
439 $im = $this->createImageResourceWithGd($img_url, $mime_type);
440 if ($im && !$this->isDecodedImageBroken($im, $width, $height, $mime_type)) {
441 $this->normalizeImageResource($im, $mime_type);
442 return $im;
443 }
444 if ($im) {
445 imagedestroy($im);
446 }
447
448 $im = $this->createImageResourceFromString($img_url);
449 if ($im && !$this->isDecodedImageBroken($im, $width, $height, $mime_type)) {
450 $this->normalizeImageResource($im, $mime_type);
451 return $im;
452 }
453 if ($im) {
454 imagedestroy($im);
455 }
456
457 $im = $this->createImageResourceWithImagick($img_url, $mime_type);
458 if ($im) {
459 $this->normalizeImageResource($im, $mime_type);
460 return $im;
461 }
462
463 return false;
464 }
465
466 /**
467 * Decode image with native GD loaders.
468 */
469 private function createImageResourceWithGd(string $img_url, string $mime_type) {
470 $create_functions = [
471 'image/jpeg' => 'imagecreatefromjpeg',
472 'image/png' => 'imagecreatefrompng',
473 'image/gif' => 'imagecreatefromgif',
474 ];
475
476 if (function_exists('imagecreatefromwebp')) {
477 $create_functions['image/webp'] = 'imagecreatefromwebp';
478 }
479
480 if (!isset($create_functions[$mime_type])) {
481 return false;
482 }
483
484 return @call_user_func($create_functions[$mime_type], $img_url);
485 }
486
487 /**
488 * Decode image from raw bytes; some optimizer outputs work here when GD loaders fail.
489 */
490 private function createImageResourceFromString(string $img_url) {
491 if (!function_exists('imagecreatefromstring')) {
492 return false;
493 }
494
495 $bytes = @file_get_contents($img_url);
496 if ($bytes === false || $bytes === '') {
497 return false;
498 }
499
500 return @imagecreatefromstring($bytes);
501 }
502
503 /**
504 * Decode image via Imagick for CMYK/ICC/progressive/optimizer outputs that GD mishandles.
505 */
506 private function createImageResourceWithImagick(string $img_url, string $mime_type) {
507 if (!class_exists('Imagick')) {
508 return false;
509 }
510
511 $imagick = null;
512
513 try {
514 $imagick = new Imagick();
515 $imagick->readImage($img_url);
516
517 if (defined('Imagick::COLORSPACE_SRGB') && method_exists($imagick, 'transformImageColorspace')) {
518 $imagick->transformImageColorspace(Imagick::COLORSPACE_SRGB);
519 } elseif (defined('Imagick::COLORSPACE_RGB')) {
520 $imagick->setImageColorspace(Imagick::COLORSPACE_RGB);
521 }
522
523 if ($mime_type === 'image/png' || $mime_type === 'image/webp') {
524 $imagick->setImageFormat('png');
525 } elseif ($mime_type === 'image/gif') {
526 $imagick->setImageFormat('gif');
527 } else {
528 $imagick->setImageBackgroundColor('white');
529 if (defined('Imagick::ALPHACHANNEL_REMOVE')) {
530 $imagick->setImageAlphaChannel(Imagick::ALPHACHANNEL_REMOVE);
531 }
532 if (method_exists($imagick, 'mergeImageLayers') && defined('Imagick::LAYERMETHOD_FLATTEN')) {
533 $imagick = $imagick->mergeImageLayers(Imagick::LAYERMETHOD_FLATTEN);
534 }
535 $imagick->setImageFormat('jpeg');
536 }
537
538 $resource = @imagecreatefromstring($imagick->getImagesBlob());
539 $imagick->clear();
540 $imagick->destroy();
541
542 return $resource ?: false;
543 } catch (Exception $e) {
544 if ($imagick instanceof Imagick) {
545 $imagick->clear();
546 $imagick->destroy();
547 }
548 error_log('WaterMark Imagick conversion failed: ' . $e->getMessage());
549 return false;
550 }
551 }
552
553 /**
554 * Detect blank/solid-color decodes that often happen with compressed or ICC JPEG/PNG files.
555 */
556 private function isDecodedImageBroken($im, int $width, int $height, string $mime_type): bool {
557 if ($width < 80 || $height < 80) {
558 return false;
559 }
560
561 $sample_points = [
562 [0, 0],
563 [$width - 1, 0],
564 [0, $height - 1],
565 [$width - 1, $height - 1],
566 [intval($width / 2), intval($height / 2)],
567 [intval($width / 4), intval($height / 4)],
568 [intval($width * 3 / 4), intval($height / 4)],
569 [intval($width / 4), intval($height * 3 / 4)],
570 [intval($width * 3 / 4), intval($height * 3 / 4)],
571 ];
572
573 $reds = [];
574 $greens = [];
575 $blues = [];
576 $transparent_count = 0;
577
578 foreach ($sample_points as $point) {
579 [$x, $y] = $point;
580 if ($x < 0 || $y < 0 || $x >= $width || $y >= $height) {
581 continue;
582 }
583
584 $rgba = @imagecolorat($im, $x, $y);
585 if ($rgba === false) {
586 continue;
587 }
588
589 $color = imagecolorsforindex($im, $rgba);
590 if (($mime_type === 'image/png' || $mime_type === 'image/webp') && $color['alpha'] >= 120) {
591 $transparent_count++;
592 continue;
593 }
594
595 $reds[] = $color['red'];
596 $greens[] = $color['green'];
597 $blues[] = $color['blue'];
598 }
599
600 if (($mime_type === 'image/png' || $mime_type === 'image/webp') && $transparent_count >= 7) {
601 return true;
602 }
603
604 if (count($reds) < 4) {
605 return false;
606 }
607
608 $red_range = max($reds) - min($reds);
609 $green_range = max($greens) - min($greens);
610 $blue_range = max($blues) - min($blues);
611
612 return ($red_range + $green_range + $blue_range) <= 3;
613 }
614
615 /**
616 * Create a composition canvas with the correct alpha settings for each mime type.
617 */
618 private function createWorkingCanvas(int $width, int $height, string $mime_type) {
619 $canvas = imagecreatetruecolor($width, $height);
620
621 if ($mime_type === 'image/png' || $mime_type === 'image/webp') {
622 imagealphablending($canvas, false);
623 imagesavealpha($canvas, true);
624 $transparent = imagecolorallocatealpha($canvas, 0, 0, 0, 127);
625 imagefilledrectangle($canvas, 0, 0, $width, $height, $transparent);
626 return $canvas;
627 }
628
629 imagealphablending($canvas, true);
630 imagesavealpha($canvas, false);
631 $background = imagecolorallocate($canvas, 255, 255, 255);
632 imagefilledrectangle($canvas, 0, 0, $width, $height, $background);
633
634 return $canvas;
635 }
636
637 /**
638 * Copy a decoded source image onto the working canvas without losing opaque pixels.
639 */
640 private function copyImageOntoCanvas($canvas, $source, int $width, int $height, string $mime_type): void {
641 if ($mime_type === 'image/png' || $mime_type === 'image/webp') {
642 imagealphablending($canvas, true);
643 imagecopy($canvas, $source, 0, 0, 0, 0, $width, $height);
644 imagealphablending($canvas, false);
645 imagesavealpha($canvas, true);
646 return;
647 }
648
649 imagealphablending($canvas, true);
650 imagecopy($canvas, $source, 0, 0, 0, 0, $width, $height);
651 }
652
653 /**
654 * Normalize loaded resources before composition.
655 */
656 private function normalizeImageResource($im, string $mime_type): void {
657 if (function_exists('imagepalettetotruecolor') && !imageistruecolor($im)) {
658 imagepalettetotruecolor($im);
659 }
660
661 if ($mime_type === 'image/png' || $mime_type === 'image/webp') {
662 imagealphablending($im, false);
663 imagesavealpha($im, true);
664 }
665 }
666
667 /**
668 * JPEG/WebP 有损输出质量(与站点 `jpeg_quality` 过滤器对齐后再限制范围)
669 */
670 private function getOutputJpegWebpQuality(): int {
671 $q = isset($this->options['output_jpeg_webp_quality'])
672 ? (int) $this->options['output_jpeg_webp_quality']
673 : 82;
674 $q = max(40, min(100, $q));
675 $filtered = (int) apply_filters('jpeg_quality', $q);
676 return max(40, min(100, $filtered));
677 }
678
679 /**
680 * PNG zlib 压缩级别 0–9(无损,�
681 影响体积与编码耗时)
682 */
683 private function getPngCompressionLevel(): int {
684 $level = isset($this->options['output_png_compression'])
685 ? (int) $this->options['output_png_compression']
686 : 6;
687 $level = max(0, min(9, $level));
688 return max(0, min(9, (int) apply_filters('wpwatermark_png_compression', $level)));
689 }
690
691 /**
692 * 水印后�
693 �许的最大体积增长比例,默认最多增长 20%。
694 */
695 private function getMaxOutputSizeGrowthRatio(): float {
696 $ratio = (float) apply_filters('wpwatermark_max_size_growth_ratio', 1.2);
697 return max(1.0, min(5.0, $ratio));
698 }
699
700 /**
701 * 自适应重编码时的最低 JPEG/WebP 质量,避�
702 �为了体积过度损伤画质。
703 */
704 private function getMinAdaptiveJpegWebpQuality(): int {
705 $quality = (int) apply_filters('wpwatermark_min_adaptive_jpeg_webp_quality', 76);
706 return max(40, min(100, $quality));
707 }
708
709 /**
710 * Helper function to save image
711 *
712 * @param resource $im
713 * @param string $filename
714 * @param string $mime_type
715 * @param string|null $source_filename
716 * @return bool
717 */
718 private function saveImage($im, string $filename, string $mime_type, ?string $source_filename = null): bool {
719 $target = $filename;
720 $temp_file = null;
721 if ($source_filename) {
722 clearstatcache(true, $source_filename);
723 }
724 $source_size = ($source_filename && file_exists($source_filename)) ? filesize($source_filename) : false;
725
726 if ($source_size !== false) {
727 $temp_file = tempnam(dirname($filename), 'wpwatermark-');
728 if ($temp_file) {
729 $target = $temp_file;
730 }
731 }
732
733 $saved = $this->writeImage($im, $target, $mime_type);
734 if (!$saved) {
735 if ($temp_file && file_exists($temp_file)) {
736 @unlink($temp_file);
737 }
738 return false;
739 }
740
741 if ($source_size !== false) {
742 clearstatcache(true, $target);
743 $this->optimizeOutputSize($im, $target, $mime_type, (int) $source_size);
744 }
745
746 if (!$temp_file) {
747 return true;
748 }
749
750 $copied = @copy($temp_file, $filename);
751 @unlink($temp_file);
752 return $copied;
753 }
754
755 /**
756 * Write an image using configured quality/compression values.
757 */
758 private function writeImage($im, string $filename, string $mime_type, ?int $quality = null, ?int $png_compression = null): bool {
759 switch ($mime_type) {
760 case 'image/jpeg':
761 return imagejpeg($im, $filename, $quality ?? $this->getOutputJpegWebpQuality());
762 case 'image/png':
763 // 第三参数为压缩级别:0 无压缩体积极大;PNG 无损,提高级别不损画质
764 return imagepng($im, $filename, $png_compression ?? $this->getPngCompressionLevel());
765 case 'image/gif':
766 return imagegif($im, $filename);
767 case 'image/webp':
768 if (function_exists('imagewebp')) {
769 return imagewebp($im, $filename, $quality ?? $this->getOutputJpegWebpQuality());
770 }
771 return false;
772 default:
773 return false;
774 }
775 }
776
777 /**
778 * Re-encode oversized outputs within conservative quality limits.
779 */
780 private function optimizeOutputSize($im, string $filename, string $mime_type, int $source_size): void {
781 if ($source_size <= 0 || !file_exists($filename)) {
782 return;
783 }
784
785 $max_size = (int) ceil($source_size * $this->getMaxOutputSizeGrowthRatio());
786 clearstatcache(true, $filename);
787 if (filesize($filename) <= $max_size) {
788 return;
789 }
790
791 if ($mime_type === 'image/png') {
792 if ($this->getPngCompressionLevel() < 9) {
793 $this->writeImage($im, $filename, $mime_type, null, 9);
794 }
795 return;
796 }
797
798 if ($mime_type !== 'image/jpeg' && $mime_type !== 'image/webp') {
799 return;
800 }
801
802 $configured_quality = $this->getOutputJpegWebpQuality();
803 $min_quality = min($configured_quality, $this->getMinAdaptiveJpegWebpQuality());
804
805 for ($quality = $configured_quality - 4; $quality >= $min_quality; $quality -= 4) {
806 $this->writeImage($im, $filename, $mime_type, $quality);
807 clearstatcache(true, $filename);
808 if (file_exists($filename) && filesize($filename) <= $max_size) {
809 return;
810 }
811 }
812 }
813
814 /**
815 * Parse hex color to RGB
816 */
817 private function parseColor($hex_color) {
818 $hex_color = ltrim($hex_color, '#');
819 return [
820 'r' => hexdec(substr($hex_color, 0, 2)),
821 'g' => hexdec(substr($hex_color, 2, 2)),
822 'b' => hexdec(substr($hex_color, 4, 2))
823 ];
824 }
825
826 /**
827 * Helper function for alpha-enabled imagecopymerge
828 *
829 * @param resource $dst_im
830 * @param resource $src_im
831 * @param int $dst_x
832 * @param int $dst_y
833 * @param int $src_x
834 * @param int $src_y
835 * @param int $src_w
836 * @param int $src_h
837 * @param int $pct
838 * @return void
839 */
840 private function imagecopymerge_alpha($dst_im, $src_im, $dst_x, $dst_y, $src_x, $src_y, $src_w, $src_h, $pct) {
841 // 确保透明度在有效范围�
842
843 $pct = min(100, max(0, $pct));
844
845 // 创建临时图像
846 $cut = imagecreatetruecolor($src_w, $src_h);
847
848 // 设置完�
849 �透明背景
850 imagealphablending($cut, false);
851 imagesavealpha($cut, true);
852 $transparent = imagecolorallocatealpha($cut, 0, 0, 0, 127);
853 imagefilledrectangle($cut, 0, 0, $src_w, $src_h, $transparent);
854
855 // 复制目标区域到临时图像
856 imagecopy($cut, $dst_im, 0, 0, intval($dst_x), intval($dst_y), $src_w, $src_h);
857
858 // 启用混合模式
859 imagealphablending($cut, true);
860
861 // 应用水印到临时图像
862 $this->imagecopymerge_alpha_pixel($cut, $src_im, 0, 0, intval($src_x), intval($src_y), $src_w, $src_h, $pct);
863
864 // 保持目标图像的透明度
865 imagealphablending($dst_im, true);
866 imagesavealpha($dst_im, true);
867
868 // 将处理后的临时图像复制回目标图像
869 imagecopy($dst_im, $cut, intval($dst_x), intval($dst_y), 0, 0, $src_w, $src_h);
870
871 // �
872
873 imagedestroy($cut);
874 }
875
876 /**
877 * 逐像素处理透明度
878 */
879 private function imagecopymerge_alpha_pixel($dst_im, $src_im, $dst_x, $dst_y, $src_x, $src_y, $src_w, $src_h, $pct) {
880 if ($pct == 0) return;
881
882 // 逐像素处理
883 for ($y = 0; $y < $src_h; ++$y) {
884 for ($x = 0; $x < $src_w; ++$x) {
885 $src_color = imagecolorsforindex($src_im, imagecolorat($src_im, $src_x + $x, $src_y + $y));
886 $dst_color = imagecolorsforindex($dst_im, imagecolorat($dst_im, $dst_x + $x, $dst_y + $y));
887
888 // 按 Porter-Duff over 合成,避�
889 �边缘泛白/发灰
890 $src_opacity = (1 - ($src_color['alpha'] / 127)) * ($pct / 100);
891 $dst_opacity = 1 - ($dst_color['alpha'] / 127);
892 $out_opacity = $src_opacity + $dst_opacity * (1 - $src_opacity);
893
894 if ($out_opacity <= 0) {
895 continue;
896 }
897
898 $final_red = (($src_color['red'] * $src_opacity) + ($dst_color['red'] * $dst_opacity * (1 - $src_opacity))) / $out_opacity;
899 $final_green = (($src_color['green'] * $src_opacity) + ($dst_color['green'] * $dst_opacity * (1 - $src_opacity))) / $out_opacity;
900 $final_blue = (($src_color['blue'] * $src_opacity) + ($dst_color['blue'] * $dst_opacity * (1 - $src_opacity))) / $out_opacity;
901 $final_alpha = 127 - intval(round($out_opacity * 127));
902
903 // 创建新颜色
904 $final_color = imagecolorallocatealpha(
905 $dst_im,
906 intval(round($final_red)),
907 intval(round($final_green)),
908 intval(round($final_blue)),
909 min(127, max(0, intval($final_alpha)))
910 );
911
912 // 设置像素
913 imagesetpixel($dst_im, $dst_x + $x, $dst_y + $y, $final_color);
914 }
915 }
916 }
917
918 /**
919 * Resize watermark image while preserving alpha channel.
920 */
921 private function resizeWatermarkResource($source, $source_width, $source_height, $target_width, $target_height) {
922 if ($target_width <= 0 || $target_height <= 0) {
923 return false;
924 }
925
926 $target = imagecreatetruecolor($target_width, $target_height);
927 imagealphablending($target, false);
928 imagesavealpha($target, true);
929
930 $transparent = imagecolorallocatealpha($target, 0, 0, 0, 127);
931 imagefilledrectangle($target, 0, 0, $target_width, $target_height, $transparent);
932
933 $success = imagecopyresampled(
934 $target,
935 $source,
936 0,
937 0,
938 0,
939 0,
940 $target_width,
941 $target_height,
942 $source_width,
943 $source_height
944 );
945
946 if (!$success) {
947 imagedestroy($target);
948 return false;
949 }
950
951 imagealphablending($target, true);
952 return $target;
953 }
954
955 /**
956 * Calculate watermark position
957 *
958 * @param string $position
959 * @param int $img_width
960 * @param int $img_height
961 * @param string $text
962 * @param int $mark_width
963 * @param int $mark_height
964 * @return array{x: int, y: int}
965 */
966 private function calculatePosition($position, $img_width, $img_height, $text = '', $mark_width = 0, $mark_height = 0, $font_size = null, $font_angle = null, $font_file = null) {
967 $margin = intval($this->options['watermark_margin']);
968
969 // For text watermark
970 if ($text !== '') {
971 $font_size = $font_size === null ? intval($this->options['text_size']) : intval($font_size);
972 $font_angle = $font_angle === null ? intval($this->options['text_angle']) : intval($font_angle);
973 $font_file = $font_file === null ? $this->font_dir . $this->options['text_font'] : $font_file;
974 $text_box = imagettfbbox($font_size, $font_angle, $font_file, $text);
975 $mark_width = abs($text_box[2] - $text_box[0]);
976 $mark_height = abs($text_box[1] - $text_box[7]);
977 }
978
979 // Calculate grid dimensions
980 $grid_width = $img_width / 3;
981 $grid_height = $img_height / 3;
982
983 // Calculate position based on grid
984 switch ($position) {
985 case 'top-left':
986 return [
987 'x' => $margin,
988 'y' => $margin
989 ];
990
991 case 'top-center':
992 return [
993 'x' => intval($grid_width + ($grid_width - $mark_width) / 2),
994 'y' => $margin
995 ];
996
997 case 'top-right':
998 return [
999 'x' => intval($img_width - $mark_width - $margin),
1000 'y' => $margin
1001 ];
1002
1003 case 'middle-left':
1004 return [
1005 'x' => $margin,
1006 'y' => intval($grid_height + ($grid_height - $mark_height) / 2)
1007 ];
1008
1009 case 'middle-center':
1010 return [
1011 'x' => intval($grid_width + ($grid_width - $mark_width) / 2),
1012 'y' => intval($grid_height + ($grid_height - $mark_height) / 2)
1013 ];
1014
1015 case 'middle-right':
1016 return [
1017 'x' => intval($img_width - $mark_width - $margin),
1018 'y' => intval($grid_height + ($grid_height - $mark_height) / 2)
1019 ];
1020
1021 case 'bottom-left':
1022 return [
1023 'x' => $margin,
1024 'y' => intval($img_height - $mark_height - $margin)
1025 ];
1026
1027 case 'bottom-center':
1028 return [
1029 'x' => intval($grid_width + ($grid_width - $mark_width) / 2),
1030 'y' => intval($img_height - $mark_height - $margin)
1031 ];
1032
1033 case 'bottom-right':
1034 default:
1035 return [
1036 'x' => intval($img_width - $mark_width - $margin),
1037 'y' => intval($img_height - $mark_height - $margin)
1038 ];
1039 }
1040 }
1041
1042 /**
1043 * Render text on a transparent layer first to avoid edge artifacts.
1044 */
1045 private function renderTextLayer($base_image, $width, $height, $text, $font_file, $font_size, $font_angle, $x, $y, $rgb, $opacity) {
1046 $layer = imagecreatetruecolor($width, $height);
1047 imagealphablending($layer, false);
1048 imagesavealpha($layer, true);
1049
1050 $transparent = imagecolorallocatealpha($layer, 0, 0, 0, 127);
1051 imagefilledrectangle($layer, 0, 0, $width, $height, $transparent);
1052
1053 imagealphablending($layer, true);
1054 $alpha = intval(round((100 - $opacity) * 127 / 100)); // 0(不透明)-127(�
1055 �透明)
1056 $text_color = imagecolorallocatealpha($layer, $rgb['r'], $rgb['g'], $rgb['b'], $alpha);
1057
1058 imagettftext(
1059 $layer,
1060 $font_size,
1061 $font_angle,
1062 $x,
1063 $y,
1064 $text_color,
1065 $font_file,
1066 $text
1067 );
1068
1069 imagealphablending($base_image, true);
1070 imagecopy($base_image, $layer, 0, 0, 0, 0, $width, $height);
1071 imagedestroy($layer);
1072 }
1073 }