| """ |
| EL HELAL Studio β Photo Layout Engine |
| Dynamically loads settings from settings.json |
| """ |
|
|
| from PIL import Image, ImageDraw, ImageFont, features, ImageOps |
| import arabic_reshaper |
| from bidi.algorithm import get_display |
| import os |
| import json |
| from datetime import date |
|
|
| |
| |
| |
| |
| _CORE_DIR = os.path.dirname(os.path.abspath(__file__)) |
| |
| _ROOT_DIR = os.path.abspath(os.path.join(_CORE_DIR, "..")) |
|
|
| |
| def find_asset(filename): |
| |
| p1 = os.path.join(_ROOT_DIR, "assets", filename) |
| if os.path.exists(p1): return p1 |
| |
| p2 = os.path.join(_CORE_DIR, filename) |
| if os.path.exists(p2): return p2 |
| |
| p3 = os.path.join(_ROOT_DIR, filename) |
| if os.path.exists(p3): return p3 |
| return p1 |
|
|
| 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) |
| |
| 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() |
|
|
| |
| 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") |
|
|
| |
| |
| |
|
|
| 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_frame(frame_name: str) -> Image.Image | None: |
| if not frame_name: return None |
| |
| if ".." in frame_name or "/" in frame_name or "\\" in frame_name: |
| print(f"Security: Invalid frame name '{frame_name}'") |
| return None |
| |
| path = find_asset(frame_name) |
| if os.path.exists(path): |
| try: |
| return Image.open(path).convert("RGBA") |
| except Exception as e: |
| print(f"Error loading frame from {path}: {e}") |
| else: |
| print(f"Frame not found at: {path}") |
| return None |
|
|
| def _load_font_with_fallback(size: int, is_arabic: bool = False) -> ImageFont.FreeTypeFont: |
| """Aggressive font loader with deep system search.""" |
| |
| 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") |
| ] |
| |
| |
| if os.name == "nt": |
| candidates += ["C:/Windows/Fonts/arialbd.ttf", "C:/Windows/Fonts/tahomabd.ttf"] |
| else: |
| |
| 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)) |
| |
| |
| 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 |
|
|
| |
| for path in candidates: |
| if path and os.path.exists(path): |
| try: |
| f_size = os.path.getsize(path) |
| if f_size < 2000: continue |
| |
| 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: |
| |
| reshaped_text = arabic_reshaper.reshape(text) |
|
|
| |
| |
| |
| 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): |
| |
| 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}" |
|
|
| |
| |
| |
|
|
| 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, |
| frame_color: tuple = None, frame_name: str = None) -> Image.Image: |
| |
| 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") |
| |
| |
| side_panel_color = frame_color if frame_color else MAROON |
|
|
| 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 |
| frame_img = _load_frame(frame_name) if frame_name 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) |
| |
| 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.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=side_panel_color) |
| |
| 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 frame_img: |
| |
| frame_w, frame_h = lw + 12, lh + 12 |
| f_overlay = frame_img.resize((frame_w, frame_h), Image.LANCZOS) |
| canvas.paste(f_overlay, (px - 6, py - 6), f_overlay) |
|
|
| 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 |
|
|