| """
|
| 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
|
|
|