- 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
180 lines
8.0 KiB
Python
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("&", "&").replace("<", "<").replace(">", ">").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 | Player: {esc(ch.get('player') or '-')} | "
|
|
f"Profession: {esc(s['profession'])} | Class: {esc(s['class'])} | 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()
|