From 79af0467f641bf6a870a5057385060be28eb6d39 Mon Sep 17 00:00:00 2001 From: mohsentaba Date: Wed, 2 Sep 2026 15:23:00 +0330 Subject: [PATCH] feat(watermark): add automatic responsive watermarking on image uploads - Create reusable watermark utility in utils/watermark.py supporting PIL, UploadedFile, bytes, and DRF serializer fields - Default position set to bottom-left with responsive 32% width scaling and alpha blending - Auto-resolve SVG watermark to high-resolution PNG - Integrate automatic watermarking into Article thumbnails, Hadith/Book Reference images, Corrections, Original Texts, and Interpretations --- apps/article/serializers_admin.py | 15 +- apps/hadis/models/hadis.py | 15 +- apps/hadis/models/reference.py | 5 + apps/hadis/models/transmitter.py | 5 + apps/hadis/serializers/serializers_admin.py | 19 ++ static/images/watermark.png | Bin 0 -> 5324 bytes utils/watermark.py | 349 ++++++++++++++++++++ 7 files changed, 403 insertions(+), 5 deletions(-) create mode 100644 static/images/watermark.png create mode 100644 utils/watermark.py diff --git a/apps/article/serializers_admin.py b/apps/article/serializers_admin.py index b754755..96834a7 100644 --- a/apps/article/serializers_admin.py +++ b/apps/article/serializers_admin.py @@ -9,14 +9,21 @@ from .models import Article, ArticleCategory, ArticleCollection, ArticleContent, class AbsoluteImageField(serializers.ImageField): def to_internal_value(self, data): uploaded = super().to_internal_value(data) - compressed_bytes = maybe_compress_uploaded_file(uploaded) - if compressed_bytes is None: + if not uploaded: return uploaded + from utils.watermark import apply_watermark_to_upload + watermarked = apply_watermark_to_upload(uploaded, position="bottom_left") + target_file = watermarked or uploaded + + compressed_bytes = maybe_compress_uploaded_file(target_file) + if compressed_bytes is None: + return target_file + return SimpleUploadedFile( - name=getattr(uploaded, "name", "image"), + name=getattr(target_file, "name", "image"), content=compressed_bytes, - content_type=getattr(uploaded, "content_type", None), + content_type=getattr(target_file, "content_type", None), ) def to_representation(self, value): diff --git a/apps/hadis/models/hadis.py b/apps/hadis/models/hadis.py index 3de4544..3985f41 100644 --- a/apps/hadis/models/hadis.py +++ b/apps/hadis/models/hadis.py @@ -367,6 +367,9 @@ class ReferenceImage(models.Model): return f'{self.reference}-{self.id}' def save(self, *args, **kwargs): + from utils.watermark import apply_watermark_to_field_file + apply_watermark_to_field_file(self.thumbnail, position="bottom_left") + if ReferenceImage.objects.filter(reference=self.reference, priority=self.priority).exists(): ReferenceImage.objects.filter( reference=self.reference, @@ -480,6 +483,11 @@ class CorrectionReferenceImage(models.Model): image = models.ImageField(upload_to='hadis/correction_images/') priority = models.IntegerField(default=0) + def save(self, *args, **kwargs): + from utils.watermark import apply_watermark_to_field_file + apply_watermark_to_field_file(self.image, position="bottom_left") + super().save(*args, **kwargs) + # --- FOR INTERPRETATIONS --- class InterpretationReference(models.Model): @@ -499,4 +507,9 @@ class InterpretationReference(models.Model): class InterpretationReferenceImage(models.Model): reference = models.ForeignKey(InterpretationReference, related_name='images', on_delete=models.CASCADE) image = models.ImageField(upload_to='hadis/interpretation_images/') - priority = models.IntegerField(default=0) \ No newline at end of file + priority = models.IntegerField(default=0) + + def save(self, *args, **kwargs): + from utils.watermark import apply_watermark_to_field_file + apply_watermark_to_field_file(self.image, position="bottom_left") + super().save(*args, **kwargs) \ No newline at end of file diff --git a/apps/hadis/models/reference.py b/apps/hadis/models/reference.py index b038adb..c87c537 100644 --- a/apps/hadis/models/reference.py +++ b/apps/hadis/models/reference.py @@ -307,6 +307,11 @@ class BookReferenceImage(models.Model): verbose_name_plural = _('Book Reference Images') ordering = ['order', '-created_at'] + def save(self, *args, **kwargs): + from utils.watermark import apply_watermark_to_field_file + apply_watermark_to_field_file(self.image, position="bottom_left") + super().save(*args, **kwargs) + def get_description(self,lang): """ Get title for a specific language diff --git a/apps/hadis/models/transmitter.py b/apps/hadis/models/transmitter.py index cdc1112..9a5487e 100644 --- a/apps/hadis/models/transmitter.py +++ b/apps/hadis/models/transmitter.py @@ -599,6 +599,11 @@ class OriginalTextReferenceImage(models.Model): image = models.ImageField(upload_to='hadis/original_text_images/') priority = models.IntegerField(default=0) + def save(self, *args, **kwargs): + from utils.watermark import apply_watermark_to_field_file + apply_watermark_to_field_file(self.image, position="bottom_left") + super().save(*args, **kwargs) + RELATION_TYPE_MAP = { 'stepfather': [{'language_code': 'ru', 'text': 'Отчим'}, {'language_code': 'ar', 'text': 'زوج الأم'}, {'language_code': 'en', 'text': 'Stepfather'}], diff --git a/apps/hadis/serializers/serializers_admin.py b/apps/hadis/serializers/serializers_admin.py index b0abaf7..a6cad2c 100644 --- a/apps/hadis/serializers/serializers_admin.py +++ b/apps/hadis/serializers/serializers_admin.py @@ -34,6 +34,25 @@ def safe_copy_data(data): class AbsoluteImageField(serializers.ImageField): + def to_internal_value(self, data): + uploaded = super().to_internal_value(data) + if not uploaded: + return uploaded + + from utils.watermark import apply_watermark_to_upload + watermarked = apply_watermark_to_upload(uploaded, position="bottom_left") + target_file = watermarked or uploaded + + compressed_bytes = maybe_compress_uploaded_file(target_file) + if compressed_bytes is None: + return target_file + + return SimpleUploadedFile( + name=getattr(target_file, "name", "image"), + content=compressed_bytes, + content_type=getattr(target_file, "content_type", None), + ) + def to_representation(self, value): if not value: return None diff --git a/static/images/watermark.png b/static/images/watermark.png new file mode 100644 index 0000000000000000000000000000000000000000..80270f5ee535a588d3dea70a74238b20dbad287f GIT binary patch literal 5324 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 Kr&P-!{C@z7oxYI( literal 0 HcmV?d00001 diff --git a/utils/watermark.py b/utils/watermark.py new file mode 100644 index 0000000..7fabd33 --- /dev/null +++ b/utils/watermark.py @@ -0,0 +1,349 @@ +""" +Watermark utility for applying logo/watermark overlays onto uploaded images. +Supports Django UploadedFile, file paths, raw bytes, and PIL Image instances. +Default position is bottom-left. +""" +import io +import os +import logging +from pathlib import Path +from typing import Optional, Union, Tuple +from PIL import Image, ImageEnhance +from django.conf import settings +from django.core.files.uploadedfile import SimpleUploadedFile, UploadedFile + +logger = logging.getLogger(__name__) + +# Default watermark search paths +DEFAULT_WATERMARK_PATHS = [ + getattr(settings, "WATERMARK_IMAGE_PATH", None), + os.path.join(settings.BASE_DIR, "static", "images", "watermark.png"), + os.path.join(settings.BASE_DIR, "static", "images", "watermark.svg"), + os.path.join(settings.BASE_DIR, "static", "images", "Frame.svg"), +] + + +def _get_default_watermark_path() -> Optional[str]: + for p in DEFAULT_WATERMARK_PATHS: + if p and os.path.exists(p): + return p + return None + + +def is_watermarkable_image(filename: str, content_type: Optional[str] = None) -> bool: + """ + Check if a file is an image format that supports watermarking. + Excludes SVG and GIF animations. + """ + name = (filename or "").lower() + ctype = (content_type or "").lower() + + if name.endswith(".gif") or name.endswith(".svg"): + return False + if ctype in {"image/gif", "image/svg+xml"}: + return False + + valid_exts = (".jpg", ".jpeg", ".png", ".webp", ".bmp", ".tif", ".tiff", ".avif") + return name.endswith(valid_exts) or ctype.startswith("image/") + + +def apply_watermark_to_image( + base_img: Image.Image, + watermark_source: Optional[Union[str, Path, Image.Image]] = None, + position: str = "bottom_left", + opacity: float = 0.85, + scale_ratio: float = 0.32, + padding: int = 24, +) -> Image.Image: + """ + Apply a watermark image onto a base PIL Image. + + Args: + base_img: PIL Image to watermark. + watermark_source: Path to watermark image or PIL Image object. + position: 'bottom_left' (default) | 'bottom_right' | 'top_right' | 'top_left' | 'center'. + opacity: Float 0.0 to 1.0. + scale_ratio: Watermark width relative to base image width (default 0.32). + padding: Pixel padding from borders. + + Returns: + New PIL Image with watermark blended. + """ + if watermark_source is None: + watermark_source = _get_default_watermark_path() + + if not watermark_source: + logger.debug("[Watermark] No watermark image found; skipping watermarking.") + return base_img + + try: + if isinstance(watermark_source, (str, Path)): + src_str = str(watermark_source) + if not os.path.exists(src_str): + logger.warning("[Watermark] File not found: %s", src_str) + return base_img + + # Pillow cannot open SVG directly; resolve to raster PNG if SVG given + if src_str.lower().endswith(".svg"): + png_candidate = os.path.splitext(src_str)[0] + ".png" + if os.path.exists(png_candidate): + src_str = png_candidate + else: + default_png = os.path.join(settings.BASE_DIR, "static", "images", "watermark.png") + if os.path.exists(default_png): + src_str = default_png + else: + logger.warning("[Watermark] Cannot open SVG without raster version: %s", src_str) + return base_img + + wm_img = Image.open(src_str).convert("RGBA") + elif isinstance(watermark_source, Image.Image): + wm_img = watermark_source.convert("RGBA") + else: + return base_img + + # Ensure base image is in RGBA for clean alpha blending + original_mode = base_img.mode + original_format = getattr(base_img, "format", None) + base_rgba = base_img.convert("RGBA") + base_w, base_h = base_rgba.size + + # Compute responsive dimensions for watermark (clear and prominent) + target_wm_w = max(int(base_w * scale_ratio), 120) + wm_aspect = wm_img.height / wm_img.width + target_wm_h = max(int(target_wm_w * wm_aspect), 35) + + # Scale down if height exceeds 45% of base image height + if target_wm_h > int(base_h * 0.45): + target_wm_h = int(base_h * 0.45) + target_wm_w = int(target_wm_h / wm_aspect) + + wm_resized = wm_img.resize((target_wm_w, target_wm_h), Image.Resampling.LANCZOS) + + # Adjust watermark opacity + if opacity < 1.0: + alpha = wm_resized.split()[3] + alpha = ImageEnhance.Brightness(alpha).enhance(max(0.0, min(1.0, opacity))) + wm_resized.putalpha(alpha) + + # Determine coordinates + pos = position.lower() + if pos == "bottom_left": + x = padding + y = base_h - target_wm_h - padding + elif pos == "bottom_right": + x = base_w - target_wm_w - padding + y = base_h - target_wm_h - padding + elif pos == "top_right": + x = base_w - target_wm_w - padding + y = padding + elif pos == "top_left": + x = padding + y = padding + elif pos == "center": + x = (base_w - target_wm_w) // 2 + y = (base_h - target_wm_h) // 2 + else: + x = padding + y = base_h - target_wm_h - padding + + # Keep inside image boundaries + x = max(0, min(x, base_w - target_wm_w)) + y = max(0, min(y, base_h - target_wm_h)) + + # Composite watermark onto base image + transparent_layer = Image.new("RGBA", (base_w, base_h), (0, 0, 0, 0)) + transparent_layer.paste(wm_resized, (x, y), mask=wm_resized) + watermarked_rgba = Image.alpha_composite(base_rgba, transparent_layer) + + # Restore original color mode if needed (e.g. RGB for JPEG) + if original_mode in ("RGB", "L"): + result = watermarked_rgba.convert(original_mode) + else: + result = watermarked_rgba + + result.format = original_format or "PNG" + return result + + except Exception as exc: + logger.error("[Watermark] Failed to apply watermark: %s", exc) + return base_img + + +def apply_watermark_to_bytes( + image_bytes: bytes, + filename: str = "image.png", + watermark_source: Optional[Union[str, Path, Image.Image]] = None, + position: str = "bottom_left", + opacity: float = 0.85, + scale_ratio: float = 0.32, + padding: int = 24, + quality: int = 90, +) -> bytes: + """ + Apply watermark to raw image bytes and return watermarked image bytes. + """ + if not image_bytes: + return image_bytes + + try: + with Image.open(io.BytesIO(image_bytes)) as img: + format_name = img.format or "JPEG" + watermarked_img = apply_watermark_to_image( + base_img=img, + watermark_source=watermark_source, + position=position, + opacity=opacity, + scale_ratio=scale_ratio, + padding=padding, + ) + + out_io = io.BytesIO() + if format_name.upper() in ("JPEG", "JPG"): + if watermarked_img.mode != "RGB": + watermarked_img = watermarked_img.convert("RGB") + watermarked_img.save(out_io, format="JPEG", quality=quality, optimize=True) + elif format_name.upper() == "WEBP": + watermarked_img.save(out_io, format="WEBP", quality=quality, method=6) + else: + watermarked_img.save(out_io, format="PNG", optimize=True) + + return out_io.getvalue() + + except Exception as exc: + logger.error("[Watermark] Error processing image bytes: %s", exc) + return image_bytes + + +def apply_watermark_to_upload( + file_obj, + watermark_source: Optional[Union[str, Path, Image.Image]] = None, + position: str = "bottom_left", + opacity: float = 0.85, + scale_ratio: float = 0.32, + padding: int = 24, + quality: int = 90, +) -> Optional[SimpleUploadedFile]: + """ + Takes an uploaded file object (e.g. from request.FILES), applies watermark, + and returns a new SimpleUploadedFile ready to be saved to an ImageField. + """ + if not file_obj: + return file_obj + + filename = getattr(file_obj, "name", "upload.jpg") + content_type = getattr(file_obj, "content_type", "image/jpeg") + + if not is_watermarkable_image(filename, content_type): + return file_obj + + try: + raw_bytes = file_obj.read() + if hasattr(file_obj, "seek"): + file_obj.seek(0) + + if not raw_bytes: + return file_obj + + watermarked_bytes = apply_watermark_to_bytes( + image_bytes=raw_bytes, + filename=filename, + watermark_source=watermark_source, + position=position, + opacity=opacity, + scale_ratio=scale_ratio, + padding=padding, + quality=quality, + ) + + return SimpleUploadedFile( + name=filename, + content=watermarked_bytes, + content_type=content_type, + ) + + except Exception as exc: + logger.error("[Watermark] Failed to process upload %s: %s", filename, exc) + if hasattr(file_obj, "seek"): + file_obj.seek(0) + return file_obj + + +def apply_watermark_to_field_file( + field_file, + position: str = "bottom_left", + opacity: float = 0.85, + scale_ratio: float = 0.32, + padding: int = 24, + quality: int = 90, +): + """ + Applies watermark to a Django FieldFile instance inside a model's save() method + if a new or uncommitted file is present. + """ + if not field_file or not hasattr(field_file, "file"): + return + + try: + file_obj = field_file.file + # Check if it's an UploadedFile instance or not yet saved to storage + if isinstance(file_obj, UploadedFile) or not getattr(field_file, "_committed", True): + watermarked = apply_watermark_to_upload( + file_obj=file_obj, + position=position, + opacity=opacity, + scale_ratio=scale_ratio, + padding=padding, + quality=quality, + ) + if watermarked: + field_file.save(field_file.name, watermarked, save=False) + except Exception as exc: + logger.error("[Watermark] Failed to watermark field file: %s", exc) + + +# DRF Serializer Field Integration +try: + from rest_framework import serializers + + class WatermarkedImageField(serializers.ImageField): + """ + DRF ImageField that automatically applies a watermark to uploaded image files. + """ + def __init__( + self, + *args, + watermark_source=None, + position="bottom_left", + opacity=0.85, + scale_ratio=0.32, + padding=24, + quality=90, + **kwargs, + ): + self.watermark_source = watermark_source + self.position = position + self.opacity = opacity + self.scale_ratio = scale_ratio + self.padding = padding + self.quality = quality + super().__init__(*args, **kwargs) + + def to_internal_value(self, data): + uploaded = super().to_internal_value(data) + if not uploaded: + return uploaded + + watermarked = apply_watermark_to_upload( + file_obj=uploaded, + watermark_source=self.watermark_source, + position=self.position, + opacity=self.opacity, + scale_ratio=self.scale_ratio, + padding=self.padding, + quality=self.quality, + ) + return watermarked or uploaded + +except ImportError: + pass