Esmaill1
Intelligent Arabic rendering: skip bidi reordering if raqm is present to avoid double-reversal
1c765b4 | """ | |
| 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 | |