Files
game-cards-poker-design/backend/apps/exports/utils.py

402 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
扑克牌渲染工具 - 使用 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.50), (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 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 = img.crop((0, img.height // 2, img.width, img.height)).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 = bot.resize((img.width, body_h - body_h // 2), Image.LANCZOS)
full.paste(top, (0, 0), top)
full.paste(bot, (0, body_h // 2), bot)
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
# 优先用单牌覆盖的背景色
bg_color = design.get('background_color', '#1B5E20' if which == 'big' else '#B71C1C')
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.15)
body_pad_y_bot = int(h * 0.20)
body_w = w - 2 * body_pad_x
body_h = h - body_pad_y_top - body_pad_y_bot
if asset:
try:
img = asset.copy()
img.thumbnail((body_w, body_h), Image.LANCZOS)
canvas.alpha_composite(img, (body_pad_x + (body_w - img.width) // 2,
body_pad_y_top + (body_h - img.height) // 2))
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 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 = card_key.split('-', 1)[1] # big / small
asset = None
for a in project.assets.filter(asset_type='joker', asset_key=which):
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'):
# 简化:背面同整体背景 + 一行文字
draw = ImageDraw.Draw(canvas)
fnt = make_text_font('Times New Roman', max(40, int(h * 0.08)), bold=True)
text = 'CARD BACK'
color = hex_to_rgba(design.get('border_color', '#333333'), 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)
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