| """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() |
|
|
|
|
| |
| |
| |
| |
| |
|
|