""" 扑克牌渲染工具 - 使用 Pillow 在服务器端生成牌面 PNG。 渲染图层结构(从下到上): 1. 背景层(颜色或图片) 2. 边框层 3. 主体层(数字牌的花色阵列 / JQK 的人物对称 / 大小王的图案 + JOKER 文字) 4. 角标层(左上 + 右下 旋转 180°) """ from PIL import Image, ImageDraw, ImageFont import io import os # 标准扑克牌花色符号(用作默认渲染) SUIT_TEXT = { 'spade': '♠', 'heart': '♥', 'club': '♣', 'diamond': '♦', } RED_SUITS = {'heart', 'diamond'} BLACK_SUITS = {'spade', 'club'} # 数字牌点数 1-10 的花色位置(相对坐标 0~1) LAYOUT_POSITIONS = { 1: [(0.50, 0.50)], 2: [(0.50, 0.25), (0.50, 0.75)], 3: [(0.50, 0.20), (0.50, 0.50), (0.50, 0.80)], 4: [(0.30, 0.25), (0.70, 0.25), (0.30, 0.75), (0.70, 0.75)], 5: [(0.30, 0.20), (0.70, 0.20), (0.50, 0.50), (0.30, 0.80), (0.70, 0.80)], 6: [(0.30, 0.20), (0.70, 0.20), (0.30, 0.50), (0.70, 0.50), (0.30, 0.80), (0.70, 0.80)], 7: [(0.30, 0.15), (0.70, 0.15), (0.50, 0.35), (0.30, 0.55), (0.70, 0.55), (0.30, 0.85), (0.70, 0.85)], 8: [(0.30, 0.15), (0.70, 0.15), (0.50, 0.32), (0.30, 0.50), (0.70, 0.50), (0.50, 0.68), (0.30, 0.85), (0.70, 0.85)], 9: [(0.30, 0.15), (0.70, 0.15), (0.50, 0.30), (0.22, 0.50), (0.50, 0.50), (0.78, 0.50), (0.50, 0.70), (0.30, 0.85), (0.70, 0.85)], 10: [(0.30, 0.15), (0.70, 0.15), (0.30, 0.35), (0.70, 0.35), (0.50, 0.45), (0.50, 0.55), (0.30, 0.65), (0.70, 0.65), (0.30, 0.85), (0.70, 0.85)], } def hex_to_rgba(hex_color, alpha=255): """将 #RRGGBB 转为 (r, g, b, a)""" if not hex_color: return (255, 255, 255, alpha) h = hex_color.lstrip('#') if len(h) == 3: h = ''.join(c * 2 for c in h) try: return (int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16), alpha) except Exception: return (255, 255, 255, alpha) def is_red(suit): return suit in RED_SUITS def is_black(suit): return suit in BLACK_SUITS def get_effective_design(project, card_key): """合并项目级设计与单牌覆盖,返回最终 design dict""" base = dict(project.design or {}) overrides = (project.card_overrides or {}).get(card_key, {}) if overrides: base.update(overrides) return base def get_effective_back_design(project): """获取背面设计配置,合并 back_design 与 card_overrides['back']""" base = dict(project.back_design or {}) overrides = (project.card_overrides or {}).get('back', {}) if overrides: base.update(overrides) return base def load_image_safe(file_path): """加载图片,找不到时返回 None""" if not file_path: return None if not os.path.exists(file_path): return None try: return Image.open(file_path).convert('RGBA') except Exception: return None def draw_background(canvas, design, project, card_key): """绘制背景层""" w, h = canvas.size bg_color = design.get('background_color', '#FFFFFF') or '#FFFFFF' canvas.paste(hex_to_rgba(bg_color, 255), (0, 0, w, h)) bg_image = design.get('background_image') if bg_image: bg_path = os.path.join(project._meta.get_field('design').model._meta.app_config and os.path.dirname(__file__) or '', bg_image) # 优先尝试绝对路径 / media 根 candidates = [bg_image, os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), 'media', bg_image)] for path in candidates: img = load_image_safe(path) if img: img = img.resize((w, h), Image.LANCZOS) canvas.alpha_composite(img) break def draw_border(canvas, design): """绘制边框""" w, h = canvas.size color = design.get('border_color', '#333333') or '#333333' width = int(design.get('border_width', 0) or 0) if width <= 0: return draw = ImageDraw.Draw(canvas) half = max(1, width // 2) draw.rectangle(((half, half), (w - half, h - half)), outline=hex_to_rgba(color, 255), width=width) def make_text_font(family, size, bold=False): """根据字体族名尝试加载字体;找不到时退回默认""" if not family: return ImageFont.load_default() # 仅在 Windows 上尝试用注册字体 if os.name == 'nt': candidates = [ family, family.replace(' ', '') + (' Bold' if bold else ''), family + (' Bold' if bold else ''), ] for name in candidates: try: # 走 Windows 字体名 return ImageFont.truetype(name + '.ttf', size) except Exception: pass # 一些常见回退 try: return ImageFont.truetype('arial.ttf', size) except Exception: pass return ImageFont.load_default() def draw_corner_index(canvas, design, suit, rank, project, card_key): """绘制左上角 + 右下角(旋转 180°)的牌面角标 角标由 上方的 rank + 小花色 组成 """ w, h = canvas.size color = hex_to_rgba(design.get('suit_symbols', {}).get(suit, {}).get('color') or ('#E53935' if is_red(suit) else '#000000'), 255) # 角标和中心花色大小(占牌面宽度比例) corner_ratio = float(design.get('corner_size_ratio', 0.13) or 0.13) pip_ratio = float(design.get('pip_size_ratio', 0.16) or 0.16) corner_size = max(20, int(w * corner_ratio)) font = make_text_font(design.get('font_family', 'Times New Roman'), corner_size) suit_font = make_text_font('Arial', int(corner_size * 0.9)) # 左上角 pad = max(8, int(w * 0.04)) rank_str = str(rank) draw = ImageDraw.Draw(canvas) rank_bbox = draw.textbbox((0, 0), rank_str, font=font) rank_h = rank_bbox[3] - rank_bbox[1] rank_w = rank_bbox[2] - rank_bbox[0] suit_bbox = draw.textbbox((0, 0), SUIT_TEXT[suit], font=suit_font) suit_h = suit_bbox[3] - suit_bbox[1] suit_w = suit_bbox[2] - suit_bbox[0] # rank 在上、suit 在下 top_y = pad canvas_rgba = canvas draw.text((pad, top_y), rank_str, font=font, fill=color) draw.text((pad, top_y + rank_h + 2), SUIT_TEXT[suit], font=suit_font, fill=color) # 右下角:旋转 180° 整体贴到 (w-pad, h-pad) block_w = max(rank_w, suit_w) block_h = rank_h + 2 + suit_h # 透明小图旋转 corner_img = Image.new('RGBA', (block_w + 4, block_h + 4), (0, 0, 0, 0)) cdraw = ImageDraw.Draw(corner_img) cdraw.text((0, 0), rank_str, font=font, fill=color) cdraw.text((0, rank_h + 2), SUIT_TEXT[suit], font=suit_font, fill=color) corner_img = corner_img.rotate(180) canvas_rgba.alpha_composite(corner_img, (w - block_w - 4 - pad, h - block_h - 4 - pad)) def draw_number_card(canvas, design, suit, rank, project, card_key): """绘制数字牌:中心花色阵列""" w, h = canvas.size pip_ratio = float(design.get('pip_size_ratio', 0.16) or 0.16) pip_size = max(40, int(w * pip_ratio)) suit_color = hex_to_rgba(design.get('suit_symbols', {}).get(suit, {}).get('color') or ('#E53935' if is_red(suit) else '#000000'), 255) font = make_text_font('Arial', pip_size) # 决定位置:优先用项目里的 number_layout 覆盖默认 rank_int = int(rank) if str(rank).isdigit() else 1 default_positions = LAYOUT_POSITIONS.get(rank_int, LAYOUT_POSITIONS[1]) layout_key = str(rank_int) user_overrides = (project.number_layout or {}).get(layout_key, []) positions = [] for i, (fx, fy) in enumerate(default_positions): if i < len(user_overrides) and isinstance(user_overrides[i], dict): dx = float(user_overrides[i].get('dx', 0)) dy = float(user_overrides[i].get('dy', 0)) scale = float(user_overrides[i].get('scale', 1)) else: dx = dy = 0 scale = 1.0 positions.append((fx + dx, fy + dy, scale)) draw = ImageDraw.Draw(canvas) symbol = SUIT_TEXT[suit] for fx, fy, scale in positions: sz = max(20, int(pip_size * scale)) fnt = make_text_font('Arial', sz) bbox = draw.textbbox((0, 0), symbol, font=fnt) tw = bbox[2] - bbox[0] th = bbox[3] - bbox[1] cx = int(fx * w) cy = int(fy * h) draw.text((cx - tw // 2, cy - th // 2), symbol, font=fnt, fill=suit_color) def draw_face_card(canvas, design, suit, rank, project, card_key, asset): """绘制 JQK 人物图:上半 + 上下翻转的下半,形成中心对称 资源找不到时退化为一个大花色符号 + 字母 """ w, h = canvas.size # 主体区域:留出上下 25% 边距给角标 body_pad_x = int(w * 0.15) body_pad_y_top = int(h * 0.13) body_pad_y_bot = int(h * 0.13) body_w = w - 2 * body_pad_x body_h = h - body_pad_y_top - body_pad_y_bot if asset: try: img = asset.copy() # 等比缩放 fill body_w x body_h img_ratio = img.width / img.height target_ratio = body_w / body_h if img_ratio > target_ratio: new_w = body_w new_h = int(body_w / img_ratio) else: new_h = body_h new_w = int(body_h * img_ratio) img = img.resize((new_w, new_h), Image.LANCZOS) # 取上半部分 + 下半部分 top = img.crop((0, 0, img.width, img.height // 2)) bot_src = img.crop((0, img.height // 2, img.width, img.height)) # symmetry_mode: 'flip' = 垂直翻转;'rotate' = 180° 旋转 sym_mode = design.get('symmetry_mode', 'flip') if sym_mode == 'rotate': # 180° 旋转 = 先水平翻转再垂直翻转(PIL 没有一步到位的 180 旋转 transpose) bot_src = bot_src.transpose(Image.FLIP_LEFT_RIGHT) bot_src = bot_src.transpose(Image.FLIP_TOP_BOTTOM) else: # 'flip'(默认) bot_src = bot_src.transpose(Image.FLIP_TOP_BOTTOM) # 拼接成 body_h 高的新图 full = Image.new('RGBA', (img.width, body_h), (0, 0, 0, 0)) top = top.resize((img.width, body_h // 2), Image.LANCZOS) bot_src = bot_src.resize((img.width, body_h - body_h // 2), Image.LANCZOS) full.paste(top, (0, 0), top) full.paste(bot_src, (0, body_h // 2), bot_src) full = full.resize((body_w, body_h), Image.LANCZOS) canvas.alpha_composite(full, (body_pad_x, body_pad_y_top)) return except Exception: pass # 退化:绘制大字 + 花色 suit_color = hex_to_rgba(design.get('suit_symbols', {}).get(suit, {}).get('color') or ('#E53935' if is_red(suit) else '#000000'), 255) big = max(80, int(h * 0.35)) fnt_rank = make_text_font('Times New Roman', big, bold=True) fnt_suit = make_text_font('Arial', big) draw = ImageDraw.Draw(canvas) rb = draw.textbbox((0, 0), rank, font=fnt_rank) rw = rb[2] - rb[0] rh = rb[3] - rb[1] draw.text(((w - rw) // 2, body_pad_y_top + (body_h - rh) // 2 - big // 2), rank, font=fnt_rank, fill=suit_color) sb = draw.textbbox((0, 0), SUIT_TEXT[suit], font=fnt_suit) sw = sb[2] - sb[0] sh = sb[3] - sb[1] draw.text(((w - sw) // 2, body_pad_y_top + (body_h - sh) // 2 + big // 4), SUIT_TEXT[suit], font=fnt_suit, fill=suit_color) def draw_joker(canvas, design, which, project, card_key, asset): """绘制大小王:独立背景 + 主体图 + 文字""" w, h = canvas.size # 大小王默认有自己的背景色(绿/红),不被整副牌的 background_color 覆盖 # 除非用户显式在 card_overrides 里设了 background_color default_bg = '#1B5E20' if which == 'big' else '#B71C1C' card_overrides = (project.card_overrides or {}).get(card_key, {}) if 'background_color' in card_overrides: bg_color = card_overrides['background_color'] else: bg_color = default_bg canvas.paste(hex_to_rgba(bg_color, 255), (0, 0, w, h)) # 主体图 body_pad_x = int(w * 0.15) body_pad_y_top = int(h * 0.18) body_pad_y_bot = int(h * 0.22) body_w = w - 2 * body_pad_x body_h = h - body_pad_y_top - body_pad_y_bot if asset: try: half_h = body_h // 2 image_dx = float(design.get('image_dx', 0)) image_dy = float(design.get('image_dy', 0)) image_scale = float(design.get('image_scale', 1)) offset_x = int(body_w * image_dx) offset_y = int(body_h * image_dy) img_copy = asset.copy() img_copy = img_copy.resize((body_w, half_h), Image.LANCZOS) if image_scale != 1: sw = max(1, int(img_copy.width * image_scale)) sh = max(1, int(img_copy.height * image_scale)) img_copy = img_copy.resize((sw, sh), Image.LANCZOS) x = body_pad_x + offset_x + (body_w - img_copy.width) // 2 y_top = body_pad_y_top + offset_y + (half_h - img_copy.height) // 2 canvas.alpha_composite(img_copy, (x, y_top)) # symmetry_mode: 'flip' = 垂直翻转;'rotate' = 180° 旋转 sym_mode = design.get('symmetry_mode', 'flip') if sym_mode == 'rotate': img_bot = img_copy.transpose(Image.ROTATE_180) else: img_bot = img_copy.transpose(Image.FLIP_TOP_BOTTOM) y_bot = body_pad_y_top + half_h + offset_y + (half_h - img_bot.height) // 2 canvas.alpha_composite(img_bot, (x, y_bot)) except Exception: pass else: # 退化 fnt = make_text_font('Times New Roman', max(80, int(h * 0.25)), bold=True) draw = ImageDraw.Draw(canvas) text = 'JOKER' color = (255, 255, 255, 255) if which == 'big' else (255, 255, 255, 255) bb = draw.textbbox((0, 0), text, font=fnt) tw, th = bb[2] - bb[0], bb[3] - bb[1] draw.text(((w - tw) // 2, (h - th) // 2), text, font=fnt, fill=color) # 文字标识 label = 'BIG' if which == 'big' else 'SMALL' text = 'JOKER' fnt_label = make_text_font('Times New Roman', max(20, int(w * 0.06)), bold=True) fnt_text = make_text_font('Times New Roman', max(16, int(w * 0.045))) color = (255, 255, 255, 255) draw = ImageDraw.Draw(canvas) bb = draw.textbbox((0, 0), text, font=fnt_text) tw = bb[2] - bb[0] th = bb[3] - bb[1] pad = max(8, int(w * 0.04)) # 左上 draw.text((pad, pad), text, font=fnt_text, fill=color) draw.text((pad, pad + th + 2), label, font=fnt_label, fill=color) # 右下 block = Image.new('RGBA', (tw + 4, 2 * th + 6), (0, 0, 0, 0)) bdraw = ImageDraw.Draw(block) bdraw.text((0, 0), text, font=fnt_text, fill=color) bdraw.text((0, th + 2), label, font=fnt_label, fill=color) block = block.rotate(180) canvas.alpha_composite(block, (w - tw - 4 - pad, h - 2 * th - 6 - pad)) def draw_back(canvas, back_design, asset): """绘制背面:素材图 + 位置微调 + 色调叠加,无素材时退化为文字""" w, h = canvas.size # 填充背景色 bg_color = back_design.get('background_color', '#1A237E') or '#1A237E' canvas.paste(hex_to_rgba(bg_color, 255), (0, 0, w, h)) body_pad_x = int(w * 0.15) body_pad_y_top = int(h * 0.18) body_pad_y_bot = int(h * 0.22) body_w = w - 2 * body_pad_x body_h = h - body_pad_y_top - body_pad_y_bot if asset: try: image_dx = float(back_design.get('image_dx', 0)) image_dy = float(back_design.get('image_dy', 0)) image_scale = float(back_design.get('image_scale', 1)) offset_x = int(body_w * image_dx) offset_y = int(body_h * image_dy) img = asset.copy() img.thumbnail((body_w, body_h), Image.LANCZOS) if image_scale != 1: sw = max(1, int(img.width * image_scale)) sh = max(1, int(img.height * image_scale)) img = img.resize((sw, sh), Image.LANCZOS) x = body_pad_x + offset_x + (body_w - img.width) // 2 y = body_pad_y_top + offset_y + (body_h - img.height) // 2 canvas.alpha_composite(img, (x, y)) # 色调叠加 pattern_color = back_design.get('pattern_color') if pattern_color: tint = Image.new('RGBA', (w, h), hex_to_rgba(pattern_color, 80)) canvas.alpha_composite(tint) except Exception: pass else: # 退化:绘制边框 + 文字 border_color = back_design.get('border_color', '#C0A050') or '#C0A050' draw = ImageDraw.Draw(canvas) border_width = int(back_design.get('border_width', 3) or 3) if border_width > 0: half = max(1, border_width // 2) draw.rectangle(((half, half), (w - half, h - half)), outline=hex_to_rgba(border_color, 255), width=border_width) fnt = make_text_font('Times New Roman', max(40, int(h * 0.08)), bold=True) text = 'CARD BACK' color = hex_to_rgba(border_color, 255) bb = draw.textbbox((0, 0), text, font=fnt) tw, th = bb[2] - bb[0], bb[3] - bb[1] draw.text(((w - tw) // 2, (h - th) // 2), text, font=fnt, fill=color) def generate_card_png(project, card_key, resolution='standard'): """根据项目配置生成单张牌 PNG""" scale_map = {'standard': 1, 'hd': 2, 'ultra-hd': 4} scale = scale_map.get(resolution, 1) w = int(project.card_width * scale) h = int(project.card_height * scale) canvas = Image.new('RGBA', (w, h), (255, 255, 255, 255)) design = get_effective_design(project, card_key) # 1. 背景 draw_background(canvas, design, project, card_key) # 2. 边框 draw_border(canvas, design) # 3. 主体内容 if card_key.startswith('joker-'): # which 是去掉前缀的 'big'/'small',但 asset_key 含 'joker-' 前缀 which = card_key.split('-', 1)[1] # big / small asset = None for a in project.assets.filter(asset_type='joker', asset_key=card_key): p = os.path.join('media', a.file_path) if a.file_path else None asset = load_image_safe(p) if p else None break draw_joker(canvas, design, which, project, card_key, asset) elif card_key in ('back', 'card-back'): back_design = get_effective_back_design(project) back_asset = None for a in project.assets.filter(asset_type='back'): p = os.path.join('media', a.file_path) if a.file_path else None back_asset = load_image_safe(p) if p else None break draw_back(canvas, back_design, back_asset) return canvas else: # 'suit-rank' parts = card_key.split('-') suit = parts[0] rank = parts[1] is_face = rank in ('J', 'Q', 'K') if is_face: asset = None for a in project.assets.filter(asset_type='face_card', asset_key=card_key): p = os.path.join('media', a.file_path) if a.file_path else None asset = load_image_safe(p) if p else None break draw_face_card(canvas, design, suit, rank, project, card_key, asset) else: # A 写成 '1',但角标用 'A' rk = 'A' if rank == '1' else rank draw_number_card(canvas, design, suit, rank, project, card_key) # 角标 draw_corner_index(canvas, design, suit, rk, project, card_key) return canvas # JQK 角标:花色小,rank 字母 J/Q/K draw_corner_index(canvas, design, suit, rank, project, card_key) return canvas