id / core /layout_engine.py
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