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

402 lines
15 KiB
Python
Raw Normal View History

"""
扑克牌渲染工具 - 使用 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