""" EL HELAL Studio – Photo Layout Engine Dynamically loads settings from settings.json """ from PIL import Image, ImageDraw, ImageFont, features import arabic_reshaper from bidi.algorithm import get_display import os import json from datetime import date # ────────────────────────────────────────────────────────────── # Paths & Config Loading # ────────────────────────────────────────────────────────────── # Use the directory of this script as a base _CORE_DIR = os.path.dirname(os.path.abspath(__file__)) # The project root is one level up from 'core' _ROOT_DIR = os.path.abspath(os.path.join(_CORE_DIR, "..")) # Search for assets in both 'assets' folder and locally def find_asset(filename): # 1. Try assets/ folder in root p1 = os.path.join(_ROOT_DIR, "assets", filename) if os.path.exists(p1): return p1 # 2. Try locally in core/ p2 = os.path.join(_CORE_DIR, filename) if os.path.exists(p2): return p2 # 3. Try root directly p3 = os.path.join(_ROOT_DIR, filename) if os.path.exists(p3): return p3 return p1 # Fallback to default path LOGO_PATH = find_asset("logo.png") ARABIC_FONT_PATH = find_asset("TYBAH.TTF") SETTINGS_PATH = os.path.join(_ROOT_DIR, "config", "settings.json") def load_settings(): defaults = { "layout": { "dpi": 300, "output_w_cm": 25.7, "output_h_cm": 12.7, "grid_rows": 2, "grid_cols": 4, "grid_gap": 10, "grid_margin": 15, "photo_bottom_pad_cm": 0.7, "brand_border": 50, "section_gap": 5, "photo_stroke_width": 1, "brand_bottom_offset": 110, "large_photo_bottom_pad": 100 }, "overlays": { "logo_size_small": 77, "logo_size_large": 95, "logo_margin": 8, "id_font_size": 50, "name_font_size": 30, "date_font_size": 19, "large_date_font_size": 24, "id_lift_offset": 45, "id_char_spacing": -3 }, "colors": { "maroon": [60, 0, 0], "dark_red": [180, 0, 0], "gold": [200, 150, 12], "white": [255, 255, 255], "text_dark": [60, 60, 60] }, "retouch": { "enabled": True, "sensitivity": 3.0, "tone_smoothing": 0.6 } } if os.path.exists(SETTINGS_PATH): try: with open(SETTINGS_PATH, "r") as f: user_settings = json.load(f) # Merge user settings into defaults for key, val in user_settings.items(): if key in defaults and isinstance(val, dict): defaults[key].update(val) else: defaults[key] = val except Exception as e: print(f"Error loading settings.json: {e}") return defaults S = load_settings() # Derived Constants DPI = S["layout"]["dpi"] OUTPUT_WIDTH = round(S["layout"]["output_w_cm"] / 2.54 * DPI) OUTPUT_HEIGHT = round(S["layout"]["output_h_cm"] / 2.54 * DPI) PHOTO_BOTTOM_PAD = round(S["layout"]["photo_bottom_pad_cm"] / 2.54 * DPI) def c(key): return tuple(S["colors"][key]) WHITE = c("white") MAROON = c("maroon") DARK_RED = c("dark_red") TEXT_DARK = c("text_dark") # ────────────────────────────────────────────────────────────── # Helpers # ────────────────────────────────────────────────────────────── def _load_logo() -> Image.Image | None: if os.path.exists(LOGO_PATH): try: return Image.open(LOGO_PATH).convert("RGBA") except Exception as e: print(f"Error loading logo from {LOGO_PATH}: {e}") else: print(f"Logo not found at: {LOGO_PATH}") return None def _load_font_with_fallback(size: int, is_arabic: bool = False) -> ImageFont.FreeTypeFont: """Aggressive font loader with deep system search.""" # 1. Assets (Downloaded via Dockerfile - Guaranteed binary files if links work) candidates = [ os.path.join(_ROOT_DIR, "assets", "arialbd.ttf"), os.path.join(_ROOT_DIR, "assets", "tahomabd.ttf"), os.path.join(_ROOT_DIR, "assets", "TYBAH.TTF") ] # 2. Add System Fonts based on priority if os.name == "nt": # Windows candidates += ["C:/Windows/Fonts/arialbd.ttf", "C:/Windows/Fonts/tahomabd.ttf"] else: # Linux / Docker - SCAN SYSTEM # We look for Noto (Arabic) and DejaVu (English/Fallback) search_dirs = ["/usr/share/fonts", "/usr/local/share/fonts"] found_system_fonts = [] for d in search_dirs: if os.path.exists(d): for root, _, files in os.walk(d): for f in files: if "NotoSansArabic-Bold" in f or "DejaVuSans-Bold" in f or "FreeSansBold" in f: found_system_fonts.append(os.path.join(root, f)) # Prioritize Noto for Arabic, DejaVu for English if is_arabic: found_system_fonts.sort(key=lambda x: "Noto" in x, reverse=True) else: found_system_fonts.sort(key=lambda x: "DejaVu" in x, reverse=True) candidates += found_system_fonts # 3. Final Search and Load for path in candidates: if path and os.path.exists(path): try: f_size = os.path.getsize(path) if f_size < 2000: continue # Skip pointers and empty files font = ImageFont.truetype(path, size) print(f"DEBUG: Using {os.path.basename(path)} for {'ARABIC' if is_arabic else 'ENGLISH'} (Size: {f_size})") return font except: continue print("CRITICAL: All font loads failed. Falling back to default.") return ImageFont.load_default() def _find_font(size: int) -> ImageFont.FreeTypeFont: return _load_font_with_fallback(size, is_arabic=False) def _arabic_font(size: int) -> ImageFont.FreeTypeFont: return _load_font_with_fallback(size, is_arabic=True) def _reshape_arabic(text: str) -> str: if not text: return "" try: # 1. Reshape the text to handle ligatures and character connections reshaped_text = arabic_reshaper.reshape(text) # 2. Reorder for RTL # If Raqm is available (usually Linux/Docker), Pillow handles reordering. # If Raqm is missing (usually Windows), we must use get_display. if features.check("raqm"): return reshaped_text return get_display(reshaped_text) except Exception as e: print(f"DEBUG: Arabic Shaping Error: {e}") return text def _resize_to_fit(img: Image.Image, max_w: int, max_h: int) -> Image.Image: w, h = img.size scale = min(max_w / w, max_h / h) return img.resize((int(w * scale), int(h * scale)), Image.LANCZOS) def _add_inner_stroke(img: Image.Image, color=(200, 200, 200), width=1) -> Image.Image: """Adds a thin inner border to the image.""" if width <= 0: return img res = img.copy() draw = ImageDraw.Draw(res) w, h = res.size for i in range(width): draw.rectangle([i, i, w - 1 - i, h - 1 - i], outline=color) return res def _paste_logo_with_stroke(target: Image.Image, logo: Image.Image, x: int, y: int, stroke_width: int = 2): mask = logo.split()[-1] white_img = Image.new("RGBA", logo.size, (255, 255, 255, 255)) for dx in range(-stroke_width, stroke_width + 1): for dy in range(-stroke_width, stroke_width + 1): if dx*dx + dy*dy <= stroke_width*stroke_width: target.paste(white_img, (x + dx, y + dy), mask) target.paste(logo, (x, y), logo) def _to_arabic_digits(text: str) -> str: latin_to_arabic = str.maketrans("0123456789", "٠١٢٣٤٥٦٧٨٩") return text.translate(latin_to_arabic) def _draw_text_with_spacing(draw: ImageDraw.ImageDraw, x: int, y: int, text: str, font: ImageFont.FreeTypeFont, fill: tuple, spacing: int = 0): # Use standard draw. Complex script shaping is handled by reshaper/bidi before this call. if spacing == 0: draw.text((x, y), text, fill=fill, font=font) return curr_x = x for char in text: draw.text((curr_x, y), char, fill=fill, font=font) curr_x += font.getlength(char) + spacing def _today_str() -> str: d = date.today() return f"{d.day}.{d.month}.{d.year}" # ────────────────────────────────────────────────────────────── # Main API # ────────────────────────────────────────────────────────────── def generate_layout(input_image: Image.Image, person_name: str = "", id_number: str = "", add_studio_name: bool = True, add_logo: bool = True, add_date: bool = True) -> Image.Image: # Reload settings to ensure any changes to settings.json are applied immediately global S, DPI, OUTPUT_WIDTH, OUTPUT_HEIGHT, PHOTO_BOTTOM_PAD, WHITE, MAROON, DARK_RED, TEXT_DARK S = load_settings() DPI = S["layout"]["dpi"] OUTPUT_WIDTH = round(S["layout"]["output_w_cm"] / 2.54 * DPI) OUTPUT_HEIGHT = round(S["layout"]["output_h_cm"] / 2.54 * DPI) PHOTO_BOTTOM_PAD = round(S["layout"]["photo_bottom_pad_cm"] / 2.54 * DPI) WHITE = c("white") MAROON = c("maroon") DARK_RED = c("dark_red") TEXT_DARK = c("text_dark") print(f"LAYOUT: Starting generation | Name: '{person_name}' | ID: '{id_number}'") print(f"LAYOUT: Options | Logo: {add_logo} | Studio: {add_studio_name} | Date: {add_date}") print(f"LAYOUT: Font Sizes | ID: {S['overlays']['id_font_size']} | Name: {S['overlays']['name_font_size']}") if input_image.mode in ("RGBA", "LA") or (input_image.mode == "P" and "transparency" in input_image.info): img = Image.new("RGB", input_image.size, WHITE) img.paste(input_image, (0, 0), input_image.convert("RGBA")) else: img = input_image.convert("RGB") logo = _load_logo() if add_logo else None today = _today_str() studio_date_text = f"EL HELAL {today}" if add_studio_name and add_date else \ "EL HELAL" if add_studio_name else \ today if add_date else "" f_date = _find_font(S["overlays"]["date_font_size"]) f_id = _find_font(S["overlays"]["id_font_size"]) f_name = _arabic_font(S["overlays"]["name_font_size"]) f_date_l = _find_font(S["overlays"]["large_date_font_size"]) f_brand = _find_font(52) display_name = _reshape_arabic(person_name) id_display = _to_arabic_digits(id_number) canvas = Image.new("RGB", (OUTPUT_WIDTH, OUTPUT_HEIGHT), WHITE) draw = ImageDraw.Draw(canvas) brand_w = round(9.2 / 2.54 * DPI) grid_w = OUTPUT_WIDTH - brand_w - S["layout"]["section_gap"] avail_w = grid_w - 2*S["layout"]["grid_margin"] - (S["layout"]["grid_cols"]-1)*S["layout"]["grid_gap"] cell_w = avail_w // S["layout"]["grid_cols"] avail_h = OUTPUT_HEIGHT - 2*S["layout"]["grid_margin"] - (S["layout"]["grid_rows"]-1)*S["layout"]["grid_gap"] cell_h = avail_h // S["layout"]["grid_rows"] photo_h = cell_h - PHOTO_BOTTOM_PAD small_raw = _resize_to_fit(img, cell_w, photo_h) # Add thin inner stroke using settings small = _add_inner_stroke(small_raw, color=(210, 210, 210), width=S["layout"]["photo_stroke_width"]) sw, sh = small.size small_dec = Image.new("RGBA", (sw, sh), (255, 255, 255, 0)) small_dec.paste(small, (0, 0)) if id_display: id_draw = ImageDraw.Draw(small_dec) sp = S["overlays"]["id_char_spacing"] tw = sum(f_id.getlength(c) for c in id_display) + (len(id_display)-1)*sp tx, ty = (sw-tw)//2, sh - S["overlays"]["id_font_size"] - S["overlays"]["id_lift_offset"] for off in [(-2,-2), (2,-2), (-2,2), (2,2), (0,-2), (0,2), (-2,0), (2,0)]: _draw_text_with_spacing(id_draw, tx+off[0], ty+off[1], id_display, f_id, WHITE, sp) _draw_text_with_spacing(id_draw, tx, ty, id_display, f_id, TEXT_DARK, sp) if logo: ls = S["overlays"]["logo_size_small"] l_img = _resize_to_fit(logo, ls, ls) _paste_logo_with_stroke(small_dec, l_img, S["overlays"]["logo_margin"], sh - l_img.size[1] - S["overlays"]["logo_margin"]) small_final = Image.new("RGB", small_dec.size, WHITE) small_final.paste(small_dec, (0, 0), small_dec) for r in range(S["layout"]["grid_rows"]): for col in range(S["layout"]["grid_cols"]): x = S["layout"]["grid_margin"] + col*(cell_w + S["layout"]["grid_gap"]) + (cell_w - sw)//2 y = S["layout"]["grid_margin"] + r*(cell_h + S["layout"]["grid_gap"]) canvas.paste(small_final, (x, y)) if studio_date_text: draw.text((x + 5, y + sh + 1), studio_date_text, fill=DARK_RED, font=f_date) if display_name: nb = f_name.getbbox(display_name) nx = x + (sw - (nb[2]-nb[0]))//2 # Draw reshaped/bidi text normally draw.text((nx, y + sh + 23), display_name, fill=(0,0,0), font=f_name) bx = grid_w + S["layout"]["section_gap"] draw.rectangle([bx, 0, OUTPUT_WIDTH, OUTPUT_HEIGHT], fill=MAROON) lav_w = brand_w - 2*S["layout"]["brand_border"] lav_h = OUTPUT_HEIGHT - 2*S["layout"]["brand_border"] - S["layout"]["large_photo_bottom_pad"] large_raw = _resize_to_fit(img, lav_w, lav_h) large = _add_inner_stroke(large_raw, color=(210, 210, 210), width=S["layout"]["photo_stroke_width"]) lw, lh = large.size px = bx + (brand_w - lw)//2 py = S["layout"]["brand_border"] + (lav_h - lh)//2 draw.rectangle([px-6, py-6, px+lw+6, py+lh+6], fill=WHITE) canvas.paste(large, (px, py)) if logo: ls = S["overlays"]["logo_size_large"] l_l = _resize_to_fit(logo, ls, ls) _paste_logo_with_stroke(canvas, l_l, px + 15, py + lh - l_l.size[1] - 15) if add_date: draw.text((px + lw//2, py + lh - 40), studio_date_text, fill=DARK_RED, font=f_date_l, anchor="ms") if add_studio_name: btb = f_brand.getbbox("EL HELAL Studio") draw.text((bx + (brand_w - (btb[2]-btb[0]))//2, OUTPUT_HEIGHT - S["layout"]["brand_bottom_offset"]), "EL HELAL Studio", fill=WHITE, font=f_brand) canvas.info["dpi"] = (DPI, DPI) return canvas