HEX
Server: Apache/2.4.52 (Ubuntu)
System: Linux spn-python 5.15.0-89-generic #99-Ubuntu SMP Mon Oct 30 20:42:41 UTC 2023 x86_64
User: arjun (1000)
PHP: 8.1.2-1ubuntu2.20
Disabled: NONE
Upload Files
File: //home/arjun/projects/aigenerator/AI-LG-backend/Ai_logo_generation/utils/banner_utils.py
from io import BytesIO
import random

from PIL import Image, ImageDraw, ImageFont


def hex_to_rgb(hex_color):
    return tuple(int(hex_color.lstrip("#")[i : i + 2], 16) for i in (0, 2, 4))


def relative_luminance(color):
    def channel_luminance(channel):
        channel /= 255.0
        return (
            channel / 12.92
            if channel <= 0.03928
            else ((channel + 0.055) / 1.055) ** 2.4
        )

    r, g, b = color
    return (
        0.2126 * channel_luminance(r)
        + 0.7152 * channel_luminance(g)
        + 0.0722 * channel_luminance(b)
    )


def contrast_ratio(color1, color2):
    lum1, lum2 = relative_luminance(color1), relative_luminance(color2)
    return (max(lum1, lum2) + 0.05) / (min(lum1, lum2) + 0.05)


def ensure_contrast(bg_color, text_color, min_ratio=4.5, max_attempts=10):
    """
    Ensures the contrast ratio between background and text color is above a certain threshold.
    Limits recursion to avoid infinite loops.
    """

    def adjust_color(color, bg_color):
        new_color = list(color)
        for i in range(3):
            if sum(bg_color) / 3 < 128:
                new_color[i] = min(255, new_color[i] + 50)
            else:
                new_color[i] = max(0, new_color[i] - 50)
        return tuple(new_color)

    attempts = 0
    while contrast_ratio(bg_color, text_color) < min_ratio and attempts < max_attempts:
        text_color = adjust_color(text_color, bg_color)
        attempts += 1

    if contrast_ratio(bg_color, text_color) < min_ratio:
        # If the color still doesn't meet the contrast ratio after max_attempts, return a safe fallback color
        return (255, 255, 255) if sum(bg_color) / 3 < 128 else (0, 0, 0)

    return text_color


def truncate_text(draw, text, font, max_width):
    """
    Truncate a line of text with ellipses if it exceeds the max width.
    Only append ellipses if truncation is needed.
    """
    # Check if the text length exceeds the max width
    text_width = draw.textlength(text, font=font)

    # If it fits within the width, return the text as is
    if text_width <= max_width:
        return text

    # Otherwise, truncate and append ellipses
    truncated_text = text
    while (
        draw.textlength(truncated_text, font=font) > max_width - 3
    ):  # 3 is the width of "..."
        truncated_text = truncated_text[:-1]  # Remove one character at a time
    truncated_text += "..."

    return truncated_text


def wrap_slogan(draw, text, font, max_width, max_words_per_line=2, max_lines=4):
    """
    Wrap the text within the max width and ensure no more than max_words_per_line words per line.
    Truncate with ellipses if the line exceeds the width.
    """
    words, lines, line = text.split(), [], []

    # Calculate 90% of max width to determine when to start a new line
    ninety_percent_width = max_width * 0.9

    for word in words:
        # If the current line has words, check its width and decide whether to continue adding
        line_text = " ".join(line)
        line_width = draw.textlength(line_text, font=font)

        # Check if adding the word exceeds 90% of max width, start a new line
        if line_width + draw.textlength(word, font=font) <= ninety_percent_width:
            line.append(word)
        else:
            # If the line already has words, finalize the line and start a new one with the current word
            if line:
                lines.append(truncate_text(draw, " ".join(line), font, max_width))
                line = [word]  # Start new line with the current word
            else:
                # If the line is empty, just add the word and truncate if needed
                line.append(word)

        # If the word itself exceeds the max width, truncate it and move to the next line
        if draw.textlength(word, font=font) > max_width:
            lines.append(truncate_text(draw, word, font, max_width))
            line = []  # Start a new line after truncating the word

        # Check if the current line exceeds max_words_per_line or max_width
        if (
            len(line) >= max_words_per_line
            or draw.textlength(" ".join(line), font=font) > max_width
        ):
            # Truncate the line if it's too long and add to the lines
            truncated_line = truncate_text(draw, " ".join(line), font, max_width)
            lines.append(truncated_line)
            line = []

            # Stop if we've reached the max number of lines
            if len(lines) == max_lines:
                lines.append("...")  # Add ellipsis at the end of the last line
                break

    # If there are remaining words and we haven't hit the line limit, process them
    if line and len(lines) < max_lines:
        truncated_line = truncate_text(draw, " ".join(line), font, max_width)
        lines.append(truncated_line)

    # If we've exceeded the max_lines, truncate the last line with ellipsis
    if len(lines) > max_lines:
        lines = lines[: max_lines - 1]  # Keep up to max_lines - 1
        lines.append("...")  # Add ellipsis for overflow text

    return lines


def wrap_description_paragraphs(
    draw, description_lines, font, max_width, max_height, max_lines=3
):
    """
    Wrap each line of the description into a paragraph. If the line exceeds the `max_width`,
    it will be truncated with ellipses. If the number of lines exceeds `max_lines`,
    the excess lines will be truncated with ellipses.
    """
    wrapped_lines = []
    total_height = 0

    for line in description_lines:
        words = line.split()  # Split the line into words
        paragraph = []

        for word in words:
            paragraph.append(word)
            paragraph_text = " ".join(paragraph)
            line_width = draw.textlength(paragraph_text, font=font)

            # If the line width exceeds the max_width, finalize the current line and start a new one
            if line_width > max_width:
                # Truncate the line if it's too long
                truncated_line = truncate_text(
                    draw, " ".join(paragraph[:-1]), font, max_width
                )
                wrapped_lines.append(truncated_line)
                paragraph = [word]  # Start a new line with the current word

        # Finalize the paragraph (add the last part of it)
        if paragraph:
            wrapped_lines.append(
                truncate_text(draw, " ".join(paragraph), font, max_width)
            )

        # Track total height of the lines
        total_height = sum(
            [draw.textbbox((0, 0), line, font=font)[3] for line in wrapped_lines]
        )

        # If we've exceeded max height, truncate the paragraph
        if total_height > max_height:
            wrapped_lines.append("...")
            break

    # If the total height exceeds max_height, ensure it doesn't exceed the max_lines and truncate
    if len(wrapped_lines) > max_lines:
        wrapped_lines = wrapped_lines[: max_lines - 1]  # Keep only up to max_lines
        wrapped_lines.append(
            "..."
        )  # Add ellipsis to the last line to indicate truncation

    return wrapped_lines


def calculate_average_color(image, region=None):
    cropped = image.crop(region) if region else image
    reduced = cropped.resize((10, 10))
    pixels = list(reduced.getdata())
    return tuple(sum(p[i] for p in pixels) // len(pixels) for i in range(3))


def draw_text_with_border(
    draw, position, text, font, text_color, border_color, border_thickness=2
):
    x, y = position
    for offset_x in range(-border_thickness, border_thickness + 1):
        for offset_y in range(-border_thickness, border_thickness + 1):
            if offset_x or offset_y:
                draw.text(
                    (x + offset_x, y + offset_y), text, font=font, fill=border_color
                )
    draw.text((x, y), text, font=font, fill=text_color)


def create_banner(
    background_bytes,
    foreground_bytes,
    font_bytes,
    banner_title,
    slogan,
    description,
    primary_color,
    secondary_color,
):
    """
    Create a portrait banner image with a background, foreground image, slogan, and description.
    """

    # Initialize images (with portrait orientation)
    banner_width, banner_height = 900, 1200
    background_image = Image.open(BytesIO(background_bytes)).resize(
        (banner_width, banner_height)
    )
    foreground_image = Image.open(BytesIO(foreground_bytes))

    # Resize foreground image
    scale_factor = min(
        banner_width / foreground_image.width, banner_height / foreground_image.height
    )
    foreground_image = foreground_image.resize(
        (
            int(foreground_image.width * scale_factor),
            int(foreground_image.height * scale_factor),
        )
    )

    # Resize the image to fit within the banner dimensions
    scale_factor = min(
        banner_width / foreground_image.width, banner_height / foreground_image.height
    )
    foreground_image = foreground_image.resize(
        (
            int(foreground_image.width * scale_factor),
            int(foreground_image.height * scale_factor),
        )
    )

    # Position foreground image horizontally (left, right, or center)
    placement = random.choice(["left", "right", "center"])

    # Crop the image to 60% of its width based on placement
    if placement == "left":
        foreground_image = foreground_image.crop(
            (
                int(foreground_image.width * 0.6),
                0,
                foreground_image.width,
                foreground_image.height,
            )
        )
    elif placement == "right":
        foreground_image = foreground_image.crop(
            (0, 0, int(foreground_image.width * 0.4), foreground_image.height)
        )

    # Determine the x-coordinate based on the placement
    if placement == "left":
        fg_x = 0  # Place on the left edge
    elif placement == "right":
        fg_x = banner_width - foreground_image.width  # Place on the right edge
    else:  # 'center'
        fg_x = (banner_width - foreground_image.width) // 2  # Center horizontally

    # Always place the foreground image at the top (fixed y-coordinate)
    fg_y = 20  # Place at the top with some padding

    # Merge foreground with background
    transparent_image = Image.new("RGBA", (banner_width, banner_height), (0, 0, 0, 0))
    transparent_image.paste(foreground_image, (fg_x, fg_y))
    background_image = Image.alpha_composite(
        background_image.convert("RGBA"), transparent_image
    )

    # Colors and contrast adjustments
    bg_color = background_image.resize((1, 1)).getpixel((0, 0))[:3]
    primary_color = ensure_contrast(
        bg_color,
        hex_to_rgb(primary_color) if isinstance(primary_color, str) else primary_color,
    )
    secondary_color = ensure_contrast(
        bg_color,
        (
            hex_to_rgb(secondary_color)
            if isinstance(secondary_color, str)
            else secondary_color
        ),
    )
    border_color = calculate_average_color(
        background_image,
        (
            banner_width // 4,
            banner_height // 4,
            banner_width * 3 // 4,
            banner_height * 3 // 4,
        ),
    )

    draw = ImageDraw.Draw(background_image)

    try:
        title_font = ImageFont.truetype(BytesIO(font_bytes), banner_height // 15)
    except Exception:
        title_font = ImageFont.load_default(banner_height // 15)

    title_lines = wrap_slogan(
        draw, banner_title, title_font, banner_width // 1.2, max_lines=2
    )
    total_title_height = sum(
        draw.textbbox((0, 0), line, font=title_font)[3] for line in title_lines
    ) + 10 * (len(title_lines) - 1)
    title_y = (
        (banner_height // 3 - total_title_height // 2)
        if slogan or description
        else (banner_height // 2 - total_title_height // 2)
    )
    title_x = banner_width // 2
    for line in title_lines:
        line_width = draw.textlength(line, font=title_font)
        draw_text_with_border(
            draw,
            (title_x - line_width // 2, title_y),
            line,
            title_font,
            primary_color,
            border_color,
        )
        title_y += title_font.size + 10

    # Handle slogan
    if slogan:
        try:
            # Load a bold font if possible (replace with an actual bold font path if necessary)
            slogan_font = ImageFont.truetype(BytesIO(font_bytes), banner_height // 21)
        except Exception:
            slogan_font = ImageFont.load_default(banner_height // 21)

        # Capitalize the slogan text before wrapping it
        capitalized_slogan = slogan.upper()

        # Wrap the capitalized and bold slogan text
        slogan_lines = wrap_slogan(
            draw,
            capitalized_slogan,
            slogan_font,
            banner_width // 1.2,
            max_words_per_line=4,
            max_lines=4,
        )

        slogan_y = title_y + 60
        slogan_x = banner_width // 2  # Center horizontally

        for line in slogan_lines:
            line_width = draw.textlength(line, font=slogan_font)
            draw_text_with_border(
                draw,
                (slogan_x - line_width // 2, slogan_y),
                line,
                slogan_font,
                primary_color,
                border_color,
            )
            slogan_y += slogan_font.size + 10

    # Handle description
    if description:
        try:
            description_font = ImageFont.truetype(
                BytesIO(font_bytes), banner_height // 36
            )
        except Exception:
            description_font = ImageFont.load_default(banner_height // 36)

        description_lines = wrap_description_paragraphs(
            draw,
            description,
            description_font,
            banner_width // 1.2,
            banner_height,
            max_lines=5,
        )

        if slogan:
            desc_y = slogan_y + 20
        else:
            desc_y = banner_height // 2
        desc_x = banner_width // 2  # Center horizontally

        for line in description_lines:
            line_width = draw.textlength(line, font=description_font)
            draw_text_with_border(
                draw,
                (desc_x - line_width // 2, desc_y),
                line,
                description_font,
                secondary_color,
                border_color,
            )
            desc_y += description_font.size + 5

    return background_image