Spaces:
Sleeping
Sleeping
| """ | |
| Character Sheet Export System - Generate D&D 5e Character Sheets | |
| Supports multiple formats: Markdown, JSON, PDF (future), PNG (future) | |
| """ | |
| from typing import Optional | |
| from pathlib import Path | |
| from datetime import datetime | |
| from src.models.character import Character, HIT_DICE_BY_CLASS | |
| class CharacterSheetExporter: | |
| """Export D&D characters to various formats""" | |
| def __init__(self): | |
| self.output_dir = Path("data/exports") | |
| self.output_dir.mkdir(parents=True, exist_ok=True) | |
| def export_to_markdown(self, character: Character, include_portrait: bool = True) -> str: | |
| """ | |
| Export character to detailed D&D 5e markdown format | |
| Args: | |
| character: Character to export | |
| include_portrait: Whether to include portrait reference | |
| Returns: | |
| Markdown formatted character sheet | |
| """ | |
| md = [] | |
| # Header with character name | |
| md.append(f"# {character.name}") | |
| md.append(f"*Level {character.level} {character.race.value} {character.character_class.value}*") | |
| md.append(f"**{character.alignment.value}**") | |
| if character.gender: | |
| md.append(f"*{character.gender}*") | |
| md.append("") | |
| md.append("---") | |
| md.append("") | |
| # Portrait reference | |
| if include_portrait and character.portrait_url: | |
| md.append(f"") | |
| md.append("") | |
| # Core Combat Stats | |
| md.append("## ⚔️ Combat Statistics") | |
| md.append("") | |
| md.append(f"**Armor Class:** {character.armor_class} ") | |
| md.append(f"**Hit Points:** {character.current_hit_points} / {character.max_hit_points} ") | |
| md.append(f"**Hit Dice:** {character.level}d{HIT_DICE_BY_CLASS.get(character.character_class, 8)} ") | |
| md.append(f"**Proficiency Bonus:** +{character.proficiency_bonus} ") | |
| md.append(f"**Initiative:** {character.stats.dexterity_modifier:+d} (Dex) ") | |
| md.append(f"**Speed:** 30 ft. ") | |
| md.append(f"**Experience Points:** {character.experience_points} XP") | |
| md.append("") | |
| # Ability Scores - Clean vertical list format | |
| md.append("## 💪 Ability Scores") | |
| md.append("") | |
| str_mod = character.stats.strength_modifier | |
| dex_mod = character.stats.dexterity_modifier | |
| con_mod = character.stats.constitution_modifier | |
| int_mod = character.stats.intelligence_modifier | |
| wis_mod = character.stats.wisdom_modifier | |
| cha_mod = character.stats.charisma_modifier | |
| md.append(f"- **Strength:** {character.stats.strength} ({str_mod:+d})") | |
| md.append(f"- **Dexterity:** {character.stats.dexterity} ({dex_mod:+d})") | |
| md.append(f"- **Constitution:** {character.stats.constitution} ({con_mod:+d})") | |
| md.append(f"- **Intelligence:** {character.stats.intelligence} ({int_mod:+d})") | |
| md.append(f"- **Wisdom:** {character.stats.wisdom} ({wis_mod:+d})") | |
| md.append(f"- **Charisma:** {character.stats.charisma} ({cha_mod:+d})") | |
| md.append("") | |
| # Saving Throws - Clean vertical list | |
| proficient_saves = [p for p in character.proficiencies if p.startswith("Saving Throws:")] | |
| if proficient_saves: | |
| md.append("## 🛡️ Saving Throws") | |
| md.append("") | |
| # Determine which saves get proficiency bonus | |
| prof_str = "Strength" in proficient_saves[0] | |
| prof_dex = "Dexterity" in proficient_saves[0] | |
| prof_con = "Constitution" in proficient_saves[0] | |
| prof_int = "Intelligence" in proficient_saves[0] | |
| prof_wis = "Wisdom" in proficient_saves[0] | |
| prof_cha = "Charisma" in proficient_saves[0] | |
| prof_bonus = character.proficiency_bonus | |
| str_save = str_mod + (prof_bonus if prof_str else 0) | |
| dex_save = dex_mod + (prof_bonus if prof_dex else 0) | |
| con_save = con_mod + (prof_bonus if prof_con else 0) | |
| int_save = int_mod + (prof_bonus if prof_int else 0) | |
| wis_save = wis_mod + (prof_bonus if prof_wis else 0) | |
| cha_save = cha_mod + (prof_bonus if prof_cha else 0) | |
| md.append(f"- **Strength:** {str_save:+d}{' ✓' if prof_str else ''}") | |
| md.append(f"- **Dexterity:** {dex_save:+d}{' ✓' if prof_dex else ''}") | |
| md.append(f"- **Constitution:** {con_save:+d}{' ✓' if prof_con else ''}") | |
| md.append(f"- **Intelligence:** {int_save:+d}{' ✓' if prof_int else ''}") | |
| md.append(f"- **Wisdom:** {wis_save:+d}{' ✓' if prof_wis else ''}") | |
| md.append(f"- **Charisma:** {cha_save:+d}{' ✓' if prof_cha else ''}") | |
| md.append("") | |
| md.append("*✓ = Proficient (includes +{} proficiency bonus)*".format(prof_bonus)) | |
| md.append("") | |
| # Proficiencies - separate into categories for clarity | |
| md.append("## 🎯 Proficiencies") | |
| md.append("") | |
| if character.proficiencies: | |
| # Group proficiencies by type | |
| skills = [p for p in character.proficiencies if p.startswith("Choose") or p.startswith("Background")] | |
| armor_weapons = [p for p in character.proficiencies if any(x in p for x in ["armor", "Weapons:", "weapons", "shields", "Tools:"])] | |
| saves_list = [p for p in character.proficiencies if p.startswith("Saving Throws:")] | |
| if skills: | |
| md.append("### Skills") | |
| for skill in skills: | |
| md.append(f"- {skill}") | |
| md.append("") | |
| if armor_weapons: | |
| md.append("### Armor, Weapons & Tools") | |
| for item in armor_weapons: | |
| md.append(f"- {item}") | |
| md.append("") | |
| else: | |
| md.append("*No proficiencies listed*") | |
| md.append("") | |
| # Background | |
| md.append("## 📖 Background & Personality") | |
| md.append("") | |
| md.append(f"**Background**: {character.background.background_type}") | |
| md.append("") | |
| if character.background.personality_traits: | |
| md.append("**Personality Traits**:") | |
| for trait in character.background.personality_traits: | |
| md.append(f"- {trait}") | |
| md.append("") | |
| if character.background.ideals: | |
| md.append(f"**Ideals**: {character.background.ideals}") | |
| md.append("") | |
| if character.background.bonds: | |
| md.append(f"**Bonds**: {character.background.bonds}") | |
| md.append("") | |
| if character.background.flaws: | |
| md.append(f"**Flaws**: {character.background.flaws}") | |
| md.append("") | |
| if character.background.backstory: | |
| md.append("**Backstory**:") | |
| md.append("") | |
| md.append(character.background.backstory) | |
| md.append("") | |
| # Class Features | |
| md.append("## ⚔️ Class Features") | |
| md.append("") | |
| if character.features: | |
| for feature in character.features: | |
| md.append(f"- **{feature}**") | |
| else: | |
| md.append("*No class features listed*") | |
| md.append("") | |
| # Equipment | |
| md.append("## 🎒 Equipment") | |
| md.append("") | |
| if character.equipment: | |
| for item in character.equipment: | |
| md.append(f"- {item}") | |
| else: | |
| md.append("*No equipment listed*") | |
| md.append("") | |
| # Spells (if applicable) | |
| if character.spells: | |
| md.append("## ✨ Spells") | |
| md.append("") | |
| for spell in character.spells: | |
| md.append(f"- {spell}") | |
| md.append("") | |
| # Notes | |
| if character.notes: | |
| md.append("## 📝 Notes") | |
| md.append("") | |
| md.append(character.notes) | |
| md.append("") | |
| # Footer | |
| md.append("---") | |
| md.append(f"*Character ID: {character.id}*") | |
| md.append(f"*Created: {character.created_at.strftime('%Y-%m-%d %H:%M')}*") | |
| md.append(f"*Last Updated: {character.updated_at.strftime('%Y-%m-%d %H:%M')}*") | |
| return "\n".join(md) | |
| def export_to_json(self, character: Character) -> str: | |
| """ | |
| Export character to JSON format | |
| Args: | |
| character: Character to export | |
| Returns: | |
| JSON string | |
| """ | |
| import json | |
| char_dict = character.model_dump() | |
| # Convert enums to strings for JSON serialization | |
| char_dict['race'] = character.race.value | |
| char_dict['character_class'] = character.character_class.value | |
| char_dict['alignment'] = character.alignment.value | |
| # Convert datetime objects | |
| char_dict['created_at'] = character.created_at.isoformat() | |
| char_dict['updated_at'] = character.updated_at.isoformat() | |
| return json.dumps(char_dict, indent=2) | |
| def export_to_html(self, character: Character) -> str: | |
| """ | |
| Export character to styled HTML format (suitable for PDF conversion) | |
| Args: | |
| character: Character to export | |
| Returns: | |
| HTML string with embedded CSS | |
| """ | |
| html = f"""<!DOCTYPE html> | |
| <html> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <title>{character.name} - D&D 5e Character Sheet</title> | |
| <style> | |
| @page {{ | |
| size: letter; | |
| margin: 0.5in; | |
| }} | |
| body {{ | |
| font-family: 'Bookman Old Style', 'Book Antiqua', serif; | |
| max-width: 8.5in; | |
| margin: 0 auto; | |
| padding: 20px; | |
| background: #f9f6f1; | |
| color: #1a1a1a; | |
| }} | |
| .header {{ | |
| text-align: center; | |
| border-bottom: 3px solid #8b0000; | |
| padding-bottom: 10px; | |
| margin-bottom: 20px; | |
| }} | |
| h1 {{ | |
| font-size: 32px; | |
| margin: 0; | |
| color: #8b0000; | |
| text-transform: uppercase; | |
| letter-spacing: 2px; | |
| }} | |
| .subtitle {{ | |
| font-size: 18px; | |
| font-style: italic; | |
| color: #444; | |
| margin: 5px 0; | |
| }} | |
| .section {{ | |
| background: white; | |
| border: 2px solid #8b0000; | |
| border-radius: 8px; | |
| padding: 15px; | |
| margin: 15px 0; | |
| box-shadow: 2px 2px 5px rgba(0,0,0,0.1); | |
| }} | |
| .section-title {{ | |
| font-size: 20px; | |
| font-weight: bold; | |
| color: #8b0000; | |
| border-bottom: 2px solid #8b0000; | |
| padding-bottom: 5px; | |
| margin-bottom: 10px; | |
| text-transform: uppercase; | |
| }} | |
| .stats-grid {{ | |
| display: grid; | |
| grid-template-columns: repeat(3, 1fr); | |
| gap: 10px; | |
| margin: 10px 0; | |
| }} | |
| .stat-box {{ | |
| text-align: center; | |
| background: #f0e6d6; | |
| border: 2px solid #8b0000; | |
| border-radius: 5px; | |
| padding: 10px; | |
| }} | |
| .stat-label {{ | |
| font-weight: bold; | |
| font-size: 12px; | |
| color: #8b0000; | |
| text-transform: uppercase; | |
| }} | |
| .stat-value {{ | |
| font-size: 24px; | |
| font-weight: bold; | |
| color: #1a1a1a; | |
| }} | |
| .abilities-grid {{ | |
| display: grid; | |
| grid-template-columns: repeat(6, 1fr); | |
| gap: 10px; | |
| margin: 10px 0; | |
| }} | |
| .ability-box {{ | |
| text-align: center; | |
| background: #f0e6d6; | |
| border: 2px solid #8b0000; | |
| border-radius: 50%; | |
| padding: 15px 5px; | |
| aspect-ratio: 1; | |
| display: flex; | |
| flex-direction: column; | |
| justify-content: center; | |
| align-items: center; | |
| }} | |
| .ability-name {{ | |
| font-weight: bold; | |
| font-size: 10px; | |
| color: #8b0000; | |
| }} | |
| .ability-score {{ | |
| font-size: 20px; | |
| font-weight: bold; | |
| }} | |
| .ability-modifier {{ | |
| font-size: 14px; | |
| color: #666; | |
| }} | |
| .list-item {{ | |
| padding: 5px 0; | |
| border-bottom: 1px dotted #ccc; | |
| }} | |
| .list-item:last-child {{ | |
| border-bottom: none; | |
| }} | |
| .portrait {{ | |
| max-width: 200px; | |
| max-height: 200px; | |
| border: 3px solid #8b0000; | |
| border-radius: 10px; | |
| margin: 10px auto; | |
| display: block; | |
| }} | |
| @media print {{ | |
| body {{ | |
| background: white; | |
| }} | |
| .section {{ | |
| page-break-inside: avoid; | |
| }} | |
| }} | |
| </style> | |
| </head> | |
| <body> | |
| <div class="header"> | |
| <h1>{character.name}</h1> | |
| <div class="subtitle">Level {character.level} {character.race.value} {character.character_class.value}</div> | |
| <div class="subtitle">{character.alignment.value}</div> | |
| {f'<div class="subtitle">{character.gender}</div>' if character.gender else ''} | |
| </div> | |
| """ | |
| # Portrait | |
| if character.portrait_url: | |
| html += f' <img src="{character.portrait_url}" class="portrait" alt="Character Portrait">\n\n' | |
| # Core Stats | |
| html += ' <div class="section">\n' | |
| html += ' <div class="section-title">Core Statistics</div>\n' | |
| html += ' <div class="stats-grid">\n' | |
| html += f' <div class="stat-box"><div class="stat-label">Armor Class</div><div class="stat-value">{character.armor_class}</div></div>\n' | |
| html += f' <div class="stat-box"><div class="stat-label">Hit Points</div><div class="stat-value">{character.current_hit_points}/{character.max_hit_points}</div></div>\n' | |
| html += f' <div class="stat-box"><div class="stat-label">Hit Dice</div><div class="stat-value">{character.level}d{HIT_DICE_BY_CLASS.get(character.character_class, 8)}</div></div>\n' | |
| html += f' <div class="stat-box"><div class="stat-label">Prof. Bonus</div><div class="stat-value">+{character.proficiency_bonus}</div></div>\n' | |
| html += f' <div class="stat-box"><div class="stat-label">Speed</div><div class="stat-value">30 ft</div></div>\n' | |
| html += f' <div class="stat-box"><div class="stat-label">Initiative</div><div class="stat-value">{character.stats.dexterity_modifier:+d}</div></div>\n' | |
| html += ' </div>\n' | |
| html += ' </div>\n\n' | |
| # Ability Scores | |
| html += ' <div class="section">\n' | |
| html += ' <div class="section-title">Ability Scores</div>\n' | |
| html += ' <div class="abilities-grid">\n' | |
| abilities = [ | |
| ("STR", character.stats.strength, character.stats.strength_modifier), | |
| ("DEX", character.stats.dexterity, character.stats.dexterity_modifier), | |
| ("CON", character.stats.constitution, character.stats.constitution_modifier), | |
| ("INT", character.stats.intelligence, character.stats.intelligence_modifier), | |
| ("WIS", character.stats.wisdom, character.stats.wisdom_modifier), | |
| ("CHA", character.stats.charisma, character.stats.charisma_modifier), | |
| ] | |
| for name, score, mod in abilities: | |
| html += f' <div class="ability-box">\n' | |
| html += f' <div class="ability-name">{name}</div>\n' | |
| html += f' <div class="ability-score">{score}</div>\n' | |
| html += f' <div class="ability-modifier">({mod:+d})</div>\n' | |
| html += f' </div>\n' | |
| html += ' </div>\n' | |
| html += ' </div>\n\n' | |
| # Skills & Proficiencies | |
| if character.proficiencies: | |
| html += ' <div class="section">\n' | |
| html += ' <div class="section-title">Skills & Proficiencies</div>\n' | |
| for prof in character.proficiencies: | |
| html += f' <div class="list-item">✓ {prof}</div>\n' | |
| html += ' </div>\n\n' | |
| # Features | |
| if character.features: | |
| html += ' <div class="section">\n' | |
| html += ' <div class="section-title">Class Features</div>\n' | |
| for feature in character.features: | |
| html += f' <div class="list-item"><strong>{feature}</strong></div>\n' | |
| html += ' </div>\n\n' | |
| # Equipment | |
| if character.equipment: | |
| html += ' <div class="section">\n' | |
| html += ' <div class="section-title">Equipment</div>\n' | |
| for item in character.equipment: | |
| html += f' <div class="list-item">{item}</div>\n' | |
| html += ' </div>\n\n' | |
| # Background | |
| html += ' <div class="section">\n' | |
| html += ' <div class="section-title">Background & Personality</div>\n' | |
| html += f' <p><strong>Background:</strong> {character.background.background_type}</p>\n' | |
| if character.background.personality_traits: | |
| html += ' <p><strong>Personality Traits:</strong></p><ul>\n' | |
| for trait in character.background.personality_traits: | |
| html += f' <li>{trait}</li>\n' | |
| html += ' </ul>\n' | |
| if character.background.ideals: | |
| html += f' <p><strong>Ideals:</strong> {character.background.ideals}</p>\n' | |
| if character.background.bonds: | |
| html += f' <p><strong>Bonds:</strong> {character.background.bonds}</p>\n' | |
| if character.background.flaws: | |
| html += f' <p><strong>Flaws:</strong> {character.background.flaws}</p>\n' | |
| if character.background.backstory: | |
| html += f' <p><strong>Backstory:</strong></p>\n' | |
| html += f' <p>{character.background.backstory}</p>\n' | |
| html += ' </div>\n\n' | |
| # Spells | |
| if character.spells: | |
| html += ' <div class="section">\n' | |
| html += ' <div class="section-title">Spells</div>\n' | |
| for spell in character.spells: | |
| html += f' <div class="list-item">{spell}</div>\n' | |
| html += ' </div>\n\n' | |
| # Footer | |
| html += ' <div style="text-align: center; margin-top: 30px; font-size: 12px; color: #666;">\n' | |
| html += f' <p>Character ID: {character.id}</p>\n' | |
| html += f' <p>Created: {character.created_at.strftime("%Y-%m-%d %H:%M")} | ' | |
| html += f'Last Updated: {character.updated_at.strftime("%Y-%m-%d %H:%M")}</p>\n' | |
| html += ' <p><em>Generated by D\'n\'D Campaign Manager</em></p>\n' | |
| html += ' </div>\n' | |
| html += '</body>\n</html>' | |
| return html | |
| def save_export(self, character: Character, format: str = "markdown") -> str: | |
| """ | |
| Save character sheet to file | |
| Args: | |
| character: Character to export | |
| format: Export format ('markdown', 'json', 'html') | |
| Returns: | |
| Path to saved file | |
| """ | |
| timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") | |
| safe_name = "".join(c if c.isalnum() or c in (' ', '_') else '_' for c in character.name) | |
| safe_name = safe_name.replace(' ', '_') | |
| if format == "markdown": | |
| content = self.export_to_markdown(character) | |
| filename = f"{safe_name}_{timestamp}.md" | |
| extension = ".md" | |
| elif format == "json": | |
| content = self.export_to_json(character) | |
| filename = f"{safe_name}_{timestamp}.json" | |
| extension = ".json" | |
| elif format == "html": | |
| content = self.export_to_html(character) | |
| filename = f"{safe_name}_{timestamp}.html" | |
| extension = ".html" | |
| else: | |
| raise ValueError(f"Unknown format: {format}") | |
| file_path = self.output_dir / filename | |
| with open(file_path, 'w', encoding='utf-8') as f: | |
| f.write(content) | |
| return str(file_path) | |
| # Global instance | |
| _exporter: Optional[CharacterSheetExporter] = None | |
| def get_exporter() -> CharacterSheetExporter: | |
| """Get or create global exporter instance""" | |
| global _exporter | |
| if _exporter is None: | |
| _exporter = CharacterSheetExporter() | |
| return _exporter | |