# utils/generators/custom.py import io import logging from PIL import Image, ImageDraw, ImageOps, ImageFilter, ImageStat from .base import BaseGenerator logger = logging.getLogger(__name__) class CustomGenerator(BaseGenerator): def build_template(self, image_data: bytes, frame_data: bytes, char_name: str) -> Image.Image: cleanup = [] try: canvas = Image.new("RGBA", (self.CANVAS_W, self.CANVAS_H), (0, 0, 0, 0)) # 1. Load the frame and IMMEDIATELY resize it to the exact card bounds (580x820) # This guarantees we never double-resample the character later. frame_raw = Image.open(io.BytesIO(frame_data)).convert("RGBA") cleanup.append(frame_raw) frame_base = frame_raw.resize((self.CARD_W, self.CARD_H), Image.Resampling.LANCZOS) cleanup.append(frame_base) f_w, f_h = self.CARD_W, self.CARD_H alpha = frame_base.split()[3] # 2. Extract dynamic theme color try: solid_pixels = alpha.point(lambda p: 255 if p > 50 else 0, mode="1") stat = ImageStat.Stat(frame_base.convert("RGB"), mask=solid_pixels) avg_r, avg_g, avg_b = [int(x) for x in stat.mean] if max(avg_r, avg_g, avg_b) < 80: avg_r, avg_g, avg_b = 160, 160, 160 self.frame_color = (avg_r, avg_g, avg_b) except Exception as e: logger.error(f"Color extraction failed: {e}") self.frame_color = None # 3. Create a STRICT binary mask for the floodfill # This prevents the "diagonal slice" by removing soft, semi-transparent pixels binary_alpha = alpha.point(lambda p: 255 if p > 20 else 0, mode="L") cleanup.append(binary_alpha) ImageDraw.floodfill(binary_alpha, (f_w // 2, f_h // 2), 128) hole_mask = binary_alpha.point(lambda p: 255 if p == 128 else 0, mode="L") cleanup.append(hole_mask) # Anti-alias the final cutout so edges are smooth bleed_mask = hole_mask.filter(ImageFilter.MaxFilter(5)).filter(ImageFilter.GaussianBlur(1.5)) cleanup.append(bleed_mask) # 4. Load Character char_raw = Image.open(io.BytesIO(image_data)).convert("RGBA") cleanup.append(char_raw) bbox = hole_mask.getbbox() if bbox: hx1, hy1, hx2, hy2 = bbox hw, hh = hx2 - hx1, hy2 - hy1 else: hx1, hy1, hw, hh = 0, 0, f_w, f_h # 5. Crop character EXACTLY ONCE to the final 1:1 pixel dimensions (LANCZOS for max quality) char_fitted = ImageOps.fit(char_raw, (hw, hh), method=Image.Resampling.LANCZOS, centering=(0.5, 0.25)) cleanup.append(char_fitted) # 6. Composite char_full = Image.new("RGBA", (f_w, f_h), (0, 0, 0, 0)) char_full.paste(char_fitted, (hx1, hy1)) cleanup.append(char_full) char_full.putalpha(bleed_mask) composite = Image.new("RGBA", (f_w, f_h), (0, 0, 0, 0)) composite.paste(char_full, (0, 0), char_full) composite.paste(frame_base, (0, 0), frame_base) cleanup.append(composite) # 7. Final Paste (Card fits perfectly on the 660x900 canvas with no extra resizing) canvas.paste(composite, (self.OFFSET_X, self.OFFSET_Y), composite) return canvas except Exception as e: logger.error(f"Cookie Cutter Frame Error: {e}", exc_info=True) return Image.new("RGBA", (self.CANVAS_W, self.CANVAS_H), (0,0,0,0)) finally: for img in cleanup: try: img.close() except: pass def apply_text(self, canvas: Image.Image, char_name: str, print_num: int, element: str, rarity: str) -> io.BytesIO: uid = ImageDraw.Draw(canvas, "RGBA") card_left = self.OFFSET_X card_right = self.OFFSET_X + self.CARD_W card_bottom = self.OFFSET_Y + self.CARD_H card_w = self.CARD_W name_sz = max(24, int(card_w * 0.080)) min_sz = 20 name_font = self.get_font(name_sz) num_font = self.get_font(int(name_sz * 0.95)) r_color = self.ELEMENT_COLORS.get(element, (168, 85, 247)) glow_solid = getattr(self, 'frame_color', r_color[:3]) final_char_name = char_name.title() # Keep text strictly inside the borders max_name_w = int(card_w * 0.85) # Dynamic Text Scaler Loop while True: try: n_bbox = uid.textbbox((0, 0), final_char_name, font=name_font) except: n_bbox = (0, 0, 20, name_sz) n_w = n_bbox[2] - n_bbox[0] if n_w <= max_name_w or name_sz <= min_sz: break name_sz -= 2 name_font = self.get_font(name_sz) name_x = card_left + (card_w - n_w) // 2 name_y = card_bottom - 120 num_text = f"{print_num}" try: num_bbox = uid.textbbox((0, 0), num_text, font=num_font) except: num_bbox = (0, 0, 40, 30) num_w = num_bbox[2] - num_bbox[0] num_h = num_bbox[3] - num_bbox[1] right_margin = max(40, int(card_w * 0.18)) num_x = card_right - right_margin - num_w num_x = max(card_left + right_margin, num_x) num_y = name_y - num_h - 10 self.draw_text_with_glow(uid, (name_x, name_y), final_char_name, name_font, core_color="white", glow_color=glow_solid) self.draw_text_with_glow(uid, (num_x, num_y), num_text, num_font, core_color="white", glow_color=glow_solid) buf = io.BytesIO() canvas.save(buf, format="WEBP", quality=95) buf.seek(0) return buf