Files
AWCharacterCreator/backend/app/export.py
T
2026-10-19 22:27:05 +02: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"]), {0: "", 1: "Proficient", 2: "Expertise"}.get(s["pips"], ""), str(s["total"])]
for s in c["skills"] if s["points"] or s["prof"] or (s["unlocked"] and s["type"] == "advanced")
],
"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(["", "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(["", "Item", "Damage / Armor / Type", "Weight"], s["gear_rows"], [22 * mm, 65 * mm, 55 * 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()