"""PIL-only share card renderer for NPCverse.""" from __future__ import annotations import io import textwrap from typing import Any from PIL import Image, ImageDraw, ImageFont CARD_W = 820 CARD_H = 480 PADDING = 34 BG_VOID = "#0d0d1a" BG_DARK = "#1a1a2e" BG_CARD = "#16213e" GOLD = "#c9a227" GOLD_DARK = "#8b6914" TEXT_GOLD = "#e8d5b7" TEXT_MUTED = "#8a7565" BORDER_DIM = "#2a2a4a" RARITY_COLORS = { "Common": "#9d9d9d", "Uncommon": "#1eff00", "Rare": "#0070dd", "Epic": "#a335ee", "Legendary": "#ff8000", } STAT_COLORS = { "high": "#ff8000", "good": "#c9a227", "mid": "#4488cc", "low": "#777777", } def _load_font(size: int, bold: bool = False, italic: bool = False) -> ImageFont.ImageFont: """Load a readable local font, falling back silently to PIL's default.""" candidates = [] if bold: candidates.extend(["arialbd.ttf", "DejaVuSans-Bold.ttf"]) elif italic: candidates.extend(["ariali.ttf", "DejaVuSerif-Italic.ttf"]) else: candidates.extend(["arial.ttf", "DejaVuSans.ttf"]) for candidate in candidates: try: return ImageFont.truetype(candidate, size) except OSError: continue return ImageFont.load_default() def _hex_to_rgb(hex_color: str) -> tuple[int, int, int]: """Convert #rrggbb into an RGB tuple.""" value = hex_color.lstrip("#") return tuple(int(value[index:index + 2], 16) for index in (0, 2, 4)) def _truncate(text: Any, limit: int) -> str: """Convert to text and truncate with an ellipsis when needed.""" safe_text = str(text or "").strip() if len(safe_text) <= limit: return safe_text return f"{safe_text[: max(0, limit - 1)].rstrip()}..." def _stat_color(value: int) -> str: """Return the display color for a stat value.""" if value >= 80: return STAT_COLORS["high"] if value >= 65: return STAT_COLORS["good"] if value >= 45: return STAT_COLORS["mid"] return STAT_COLORS["low"] def _safe_int(value: Any, default: int = 0) -> int: """Convert arbitrary values to ints without raising.""" try: return int(value) except (TypeError, ValueError): return default def _draw_decorative_border(draw: ImageDraw.ImageDraw) -> None: """Draw the ornamental frame around the card.""" draw.rectangle( [12, 12, CARD_W - 12, CARD_H - 12], outline=GOLD_DARK, width=2, ) draw.rectangle( [20, 20, CARD_W - 20, CARD_H - 20], outline=BORDER_DIM, width=1, ) corner = 42 for x, y, sx, sy in [ (22, 22, 1, 1), (CARD_W - 22, 22, -1, 1), (22, CARD_H - 22, 1, -1), (CARD_W - 22, CARD_H - 22, -1, -1), ]: draw.line([(x, y), (x + sx * corner, y)], fill=GOLD, width=2) draw.line([(x, y), (x, y + sy * corner)], fill=GOLD, width=2) draw.ellipse( [x - 3, y - 3, x + 3, y + 3], fill=GOLD, ) def _draw_rarity_glow(draw: ImageDraw.ImageDraw, rarity: str) -> None: """Simulate rarity glow with colored corner triangles.""" if rarity == "Epic": draw.polygon( [(CARD_W, 0), (CARD_W - 145, 0), (CARD_W, 145)], fill=(163, 53, 238, 48), ) elif rarity == "Legendary": color = (255, 128, 0, 62) size = 125 draw.polygon([(0, 0), (size, 0), (0, size)], fill=color) draw.polygon([(CARD_W, 0), (CARD_W - size, 0), (CARD_W, size)], fill=color) draw.polygon([(0, CARD_H), (size, CARD_H), (0, CARD_H - size)], fill=color) draw.polygon( [(CARD_W, CARD_H), (CARD_W - size, CARD_H), (CARD_W, CARD_H - size)], fill=color, ) def _draw_stat_bars( draw: ImageDraw.ImageDraw, stats: dict[str, Any], font: ImageFont.ImageFont, x: int, y: int, ) -> int: """Draw six NPC stat bars and return the next y coordinate.""" labels = [ ("strength", "STR"), ("intelligence", "INT"), ("charisma", "CHA"), ("luck", "LCK"), ("stealth", "STL"), ("chaos", "CHS"), ] bar_w = 185 bar_h = 10 row_h = 24 for index, (key, label) in enumerate(labels): row_y = y + index * row_h value = max(1, min(100, _safe_int(stats.get(key), 50))) fill_w = int(bar_w * (value / 100)) color = _stat_color(value) draw.text((x, row_y - 4), label, fill=TEXT_MUTED, font=font) draw.rectangle([x + 42, row_y, x + 42 + bar_w, row_y + bar_h], fill=BG_VOID) draw.rectangle( [x + 42, row_y, x + 42 + fill_w, row_y + bar_h], fill=color, ) draw.rectangle( [x + 42, row_y, x + 42 + bar_w, row_y + bar_h], outline=BORDER_DIM, width=1, ) draw.text((x + 238, row_y - 4), str(value), fill=TEXT_GOLD, font=font) return y + len(labels) * row_h def _draw_divider(draw: ImageDraw.ImageDraw, center_x: int, y: int, max_radius: int = 240) -> None: """Draw a dotted horizontal rule that fades outward from center.""" gold_rgb = _hex_to_rgb(GOLD) for offset in range(0, max_radius, 8): alpha_scale = 1 - (offset / max_radius) color = (*gold_rgb, int(190 * alpha_scale)) radius = 2 if offset < 70 else 1 draw.ellipse( [center_x + offset - radius, y - radius, center_x + offset + radius, y + radius], fill=color, ) if offset: draw.ellipse( [center_x - offset - radius, y - radius, center_x - offset + radius, y + radius], fill=color, ) def _wrap_text(text: str, width: int) -> str: """Wrap text for PIL drawing.""" return "\n".join(textwrap.wrap(str(text or ""), width=width)) def generate_share_card(npc: dict) -> Image.Image: """Generate a PNG-ready NPCverse share card for an NPC dictionary.""" npc = npc or {} stats = npc.get("stats") or {} passive = npc.get("passive_ability") or {} ultimate = npc.get("ultimate") or {} rarity = str(npc.get("rarity", "Common") or "Common") rarity_color = RARITY_COLORS.get(rarity, RARITY_COLORS["Common"]) img = Image.new("RGBA", (CARD_W, CARD_H), BG_VOID) draw = ImageDraw.Draw(img, "RGBA") draw.rectangle([18, 18, CARD_W - 18, CARD_H - 18], fill=BG_CARD) _draw_rarity_glow(draw, rarity) _draw_decorative_border(draw) title_font = _load_font(34, bold=True) subtitle_font = _load_font(18) label_font = _load_font(13, bold=True) body_font = _load_font(16) small_font = _load_font(12) quote_font = _load_font(15, italic=True) name = _truncate(npc.get("name", "Unknown Hero"), 28) npc_class = _truncate(npc.get("class", "Adventurer"), 24) level = _safe_int(npc.get("level"), 1) title = _truncate(npc.get("title", "Wanderer"), 40) alignment = _truncate(npc.get("alignment", "Unaligned"), 26) lore = _wrap_text(_truncate(npc.get("lore", "A mysterious figure steps into legend."), 210), 58) faction = _truncate(npc.get("faction", "Unaffiliated"), 28) world = _truncate(npc.get("world", "Unknown Realm"), 28) opening_line = _truncate(npc.get("opening_line", ""), 80) left_x = PADDING + 8 right_x = 472 draw.text((left_x, 48), name, fill=GOLD, font=title_font) draw.text((left_x, 92), title, fill=TEXT_GOLD, font=subtitle_font) draw.text((left_x, 122), f"{npc_class} - Lv.{level}", fill=TEXT_MUTED, font=body_font) badge_x = left_x badge_y = 154 draw.rounded_rectangle( [badge_x, badge_y, badge_x + 146, badge_y + 30], radius=6, fill=BG_DARK, outline=rarity_color, width=2, ) draw.text((badge_x + 12, badge_y + 7), rarity.upper(), fill=rarity_color, font=label_font) draw.text((badge_x + 164, badge_y + 7), alignment, fill=TEXT_MUTED, font=label_font) stats_bottom = _draw_stat_bars(draw, stats, small_font, left_x, 214) _draw_divider(draw, center_x=left_x + 150, y=stats_bottom + 12, max_radius=150) draw.text((right_x, 58), "PASSIVE", fill=GOLD, font=label_font) draw.text( (right_x, 78), _wrap_text( f"{passive.get('name', 'Hidden Spark')}: " f"{passive.get('description', 'A quiet power waits beneath the surface.')}", 36, ), fill=TEXT_GOLD, font=body_font, spacing=4, ) draw.text((right_x, 164), "ULTIMATE", fill=GOLD, font=label_font) draw.text( (right_x, 184), _wrap_text( f"{ultimate.get('name', 'Final Gambit')}: " f"{ultimate.get('description', 'Turns desperation into one decisive moment.')}", 36, ), fill=TEXT_GOLD, font=body_font, spacing=4, ) draw.text((left_x, 336), "LORE", fill=GOLD, font=label_font) draw.text((left_x, 356), lore, fill=TEXT_GOLD, font=body_font, spacing=4) meta_y = 414 draw.text((left_x, meta_y), f"Faction: {faction}", fill=TEXT_MUTED, font=small_font) draw.text((left_x + 250, meta_y), f"World: {world}", fill=TEXT_MUTED, font=small_font) if opening_line: draw.text((left_x, meta_y + 22), f"❝ {_truncate(opening_line, 80)}", fill=TEXT_GOLD, font=quote_font) draw.text((right_x, 414), "NPCverse", fill=GOLD, font=subtitle_font) draw.text((right_x, 442), "Summoned from a photo", fill=TEXT_MUTED, font=small_font) return img.convert("RGB") def card_to_bytes(img: Image.Image) -> bytes: """Convert a PIL image to PNG bytes.""" buffer = io.BytesIO() img.save(buffer, format="PNG") return buffer.getvalue() # Test with: python share_card.py # if __name__ == "__main__": # test_npc = {"name": "Test Hero", "class": "Mage", "level": 42, ...} # img = generate_share_card(test_npc) # img.save("test_card.png")