Files
AWCharacterCreator/backend/app/export.py
T
Claude 5f3b2b2874 Add accounts, presets, live sheet panel and equipment tiles
- Login with registration code, sessions, rate limiting, per-user characters
- Share characters with other users (read-only), admin CLI
- Rules presets with a Presets tab, applied/pending changes with diff in a side sheet
- Equipment as tiles with amounts, starting kit removed
- Rename nav tab to "Character", add "New character" button
- Stop tracking _to_delete/

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
2026-10-20 20:37:09 +00:00

180 lines
8.0 KiB
Python

"""Character sheet as DOCX and PDF."""
import io
from docx import Document
from docx.enum.text import WD_ALIGN_PARAGRAPH
from docx.shared import Pt
from reportlab.lib import colors
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
from reportlab.lib.units import mm
from reportlab.platypus import Paragraph, SimpleDocTemplate, Table, TableStyle
from .derive import compute, kit_items
SOURCE = {"noxium": "Noxium", "diesium": "Diesium", "hybrid": "Hybrid", "precyber": "Pre-meteor cybernetics"}
KIND = {"mutation": "Mutation", "implant": "Implant"}
def _sheet(ch: dict, rules: dict) -> dict:
"""Prepares all data once for both formats."""
c = compute(ch, rules)
prof = next((p for p in rules["professions"] if p["id"] == ch.get("profession")), None)
talents = [t for t in rules["talents"] if t["id"] in ch.get("talents", [])]
cls = next((k for k in rules.get("classes", []) if k["id"] == ch.get("class_id")), None)
sub = next((x for x in (cls or {}).get("subclasses", []) if x["id"] == ch.get("subclass_id")), None)
gear_rows = []
for label, item, qty in kit_items(ch, rules):
name = item["name"] + (f" x{qty}" if qty > 1 else "")
gear_rows.append([label, name, item["stat"], f"{round(item['kg'] * qty, 1)} kg"])
preset = next((x["name"] for x in rules.get("presets", []) if x["id"] == ch.get("preset")), "Custom")
return {
"c": c,
"preset": preset,
"class": " / ".join(x["name"] for x in (cls, sub) if x) or "-",
"profession": prof["name"] if prof else "-",
"talents": talents,
"gear_rows": gear_rows,
"other": (ch.get("gear") or {}).get("other", ""),
"attr_rows": [[a["name"], str(c["attrs"][a["id"]]), str(c["mods"][a["id"]])] for a in rules["attributes"]],
"derived_rows": [[d["name"], str(c["derived"][d["id"]])] for d in rules["derived"]],
"skill_rows": [
[s["name"], "/".join(a["short"] for a in rules["attributes"] if a["id"] in s["attrs"]),
str(s["points"]), {1: "Proficient", 2: "Expertise"}.get(s["pips"]) or ("" if s["usable"] else "Locked"), str(s["total"])]
for s in c["skills"]
],
"augment_rows": [[a["name"], KIND.get(a["kind"], a["kind"]), SOURCE.get(a["source"]) or "-", str(a["cost"])]
for a in c["augments"]],
}
def to_docx(ch: dict, rules: dict) -> bytes:
s = _sheet(ch, rules)
c = s["c"]
doc = Document()
doc.styles["Normal"].font.name = "Calibri"
doc.styles["Normal"].font.size = Pt(10)
title = doc.add_heading(ch.get("name") or "Nameless", 0)
title.alignment = WD_ALIGN_PARAGRAPH.LEFT
doc.add_paragraph(f"Arcane Wasteland | Player: {ch.get('player') or '-'} | Profession: {s['profession']} | Class: {s['class']} | Rules: {s['preset']}")
if ch.get("concept"):
doc.add_paragraph().add_run(ch["concept"]).italic = True
def table(head, rows):
t = doc.add_table(rows=1, cols=len(head))
t.style = "Light Grid Accent 1"
for i, h in enumerate(head):
t.rows[0].cells[i].text = h
for r in rows:
cells = t.add_row().cells
for i, v in enumerate(r):
cells[i].text = v
doc.add_heading("Attributes", 1)
table(["Attribute", "Value", "Mod"], s["attr_rows"])
doc.add_heading("Derived stats", 1)
table(["Stat", ""], s["derived_rows"])
doc.add_heading("Skills", 1)
if s["skill_rows"]:
table(["Skill", "Attr.", "Points", "Training", "Target"], s["skill_rows"])
else:
doc.add_paragraph("No skill points spent.")
doc.add_heading("Talents", 1)
for t in s["talents"]:
doc.add_paragraph(f"{t['name']}: {t['desc']}", style="List Bullet")
if not s["talents"]:
doc.add_paragraph("-")
doc.add_heading(f"Mutations and implants ({c['capacity_used']} / {c['capacity_max']} capacity)", 1)
if s["augment_rows"]:
table(["Name", "Type", "Source", "Cost"], s["augment_rows"])
else:
doc.add_paragraph("-")
doc.add_heading(f"Equipment (carried {c['weight']} / {c['carry_max']} kg)", 1)
if s["gear_rows"]:
table(["Category", "Item", "Damage / Armor / Type", "Weight"], s["gear_rows"])
else:
doc.add_paragraph("-")
if s["other"]:
p = doc.add_paragraph()
p.add_run("Other: ").bold = True
p.add_run(s["other"])
for key, head in (("background", "Background"), ("notes", "Notes")):
if ch.get(key):
doc.add_heading(head, 1)
doc.add_paragraph(ch[key])
buf = io.BytesIO()
doc.save(buf)
return buf.getvalue()
def to_pdf(ch: dict, rules: dict) -> bytes:
s = _sheet(ch, rules)
c = s["c"]
buf = io.BytesIO()
doc = SimpleDocTemplate(buf, pagesize=A4, leftMargin=18 * mm, rightMargin=18 * mm,
topMargin=16 * mm, bottomMargin=16 * mm,
title=ch.get("name") or "Character", author=ch.get("player") or "")
st = getSampleStyleSheet()
h1 = ParagraphStyle("h1", parent=st["Heading1"], fontSize=22, spaceAfter=2)
h2 = ParagraphStyle("h2", parent=st["Heading2"], fontSize=12, spaceBefore=10, spaceAfter=4,
textColor=colors.HexColor("#2f5d3a"))
body = ParagraphStyle("b", parent=st["BodyText"], fontSize=9.5, leading=12.5)
def esc(t):
return (t or "").replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("\n", "<br/>")
def tbl(head, rows, widths=None):
t = Table([head] + rows, colWidths=widths, repeatRows=1)
t.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#2f5d3a")),
("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
("FONTSIZE", (0, 0), (-1, -1), 9),
("GRID", (0, 0), (-1, -1), 0.4, colors.HexColor("#b9c4bb")),
("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.HexColor("#f1f5f2")]),
("TOPPADDING", (0, 0), (-1, -1), 3), ("BOTTOMPADDING", (0, 0), (-1, -1), 3),
]))
return t
el = [Paragraph(esc(ch.get("name") or "Nameless"), h1),
Paragraph(f"Arcane Wasteland &nbsp;|&nbsp; Player: {esc(ch.get('player') or '-')} &nbsp;|&nbsp; "
f"Profession: {esc(s['profession'])} &nbsp;|&nbsp; Class: {esc(s['class'])} &nbsp;|&nbsp; Rules: {esc(s['preset'])}", body)]
if ch.get("concept"):
el.append(Paragraph(f"<i>{esc(ch['concept'])}</i>", body))
el += [Paragraph("Attributes", h2), tbl(["Attribute", "Value", "Mod"], s["attr_rows"], [80 * mm, 30 * mm, 30 * mm]),
Paragraph("Derived stats", h2), tbl(["Stat", ""], s["derived_rows"], [80 * mm, 30 * mm]),
Paragraph("Skills", h2)]
if s["skill_rows"]:
el.append(tbl(["Skill", "Attr.", "Points", "Training", "Target"], s["skill_rows"],
[60 * mm, 22 * mm, 22 * mm, 28 * mm, 25 * mm]))
else:
el.append(Paragraph("No skill points spent.", body))
el.append(Paragraph("Talents", h2))
for t in s["talents"]:
el.append(Paragraph(f"<b>{esc(t['name'])}</b>: {esc(t['desc'])}", body))
if not s["talents"]:
el.append(Paragraph("-", body))
el.append(Paragraph(f"Mutations and implants ({c['capacity_used']} / {c['capacity_max']} capacity)", h2))
if s["augment_rows"]:
el.append(tbl(["Name", "Type", "Source", "Cost"], s["augment_rows"], [60 * mm, 30 * mm, 50 * mm, 22 * mm]))
else:
el.append(Paragraph("-", body))
el.append(Paragraph(f"Equipment (carried {c['weight']} / {c['carry_max']} kg)", h2))
if s["gear_rows"]:
el.append(tbl(["Category", "Item", "Damage / Armor / Type", "Weight"], s["gear_rows"], [30 * mm, 60 * mm, 50 * mm, 22 * mm]))
else:
el.append(Paragraph("-", body))
if s["other"]:
el.append(Paragraph(f"<b>Other:</b> {esc(s['other'])}", body))
for key, head in (("background", "Background"), ("notes", "Notes")):
if ch.get(key):
el += [Paragraph(head, h2), Paragraph(esc(ch[key]), body)]
doc.build(el)
return buf.getvalue()