DnD_Campaign_Manager / src /utils /character_sheet_exporter.py
official.ghost.logic
Deploy D&D Campaign Manager v2
71b378e
"""
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"![Character Portrait]({character.portrait_url})")
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