402 lines
15 KiB
Python
402 lines
15 KiB
Python
"""
|
||
扑克牌渲染工具 - 使用 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
|