diff --git a/README.md b/README.md
index f68ed3e..f47829e 100644
--- a/README.md
+++ b/README.md
@@ -86,6 +86,10 @@ Each wizard page has a **Reset this page** button that clears only that page's d
The Summary page and the sheet tabs in the lobbies show the character as **A4 pages**: page 1 has name, attributes, derived stats and all skills (proficiency dots, attribute, points, total), page 2 has talents, mutations and implants, equipment, background and a ruled notes area. On the Summary page, **Print / PDF (A4)** opens just the pages in a clean window; choose "Save as PDF" in the print dialog (margins none, background graphics on if your browser asks). Hover or tap a value to see how it is made up (not printed). **Light / Dark** (in the lobby it sits above the sheet) switches the colours of the sheet on screen (remembered in the browser; printed pages are always light). The Word and PDF downloads on the Summary page are separate exports with their own layout.
+### Character portrait
+
+On the **Concept** page *Add picture* takes a PNG, JPEG or WebP: move and zoom it inside the square window, *Use this* stores a small 256 px picture inside the character (so it is saved, shared, exported as JSON and loaded with it). The server checks and re-encodes it. The picture appears in the header of the character sheet (also when printing), as icon in the player bar of the lobby and as the token of the character on the map (in the player's colour ring). Only members of the lobby get the picture through `/api/lobbies/{id}/portrait/{username}`. The PDF / Word export from the server does not contain it yet.
+
### Rules editor
Admins get an **Editor** tab to add, change and delete **items, item categories, skills, skill groups and professions** with forms. Admins are the usernames in `AW_ADMINS` (comma separated, set in `.env`); without that setting only the first account ever created is admin.
diff --git a/backend/app/main.py b/backend/app/main.py
index dafd6c7..7e768ad 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -9,7 +9,7 @@ from pathlib import Path
import asyncio
from fastapi import APIRouter, Body, Depends, FastAPI, HTTPException, Request, Response, WebSocket, WebSocketDisconnect
-from fastapi.responses import FileResponse, JSONResponse
+from fastapi.responses import FileResponse, JSONResponse, Response
from fastapi.staticfiles import StaticFiles
from . import auth
@@ -17,6 +17,7 @@ from .derive import compute
from . import rules_edit
from . import maps as maps_mod
from . import sounds as sounds_mod
+from . import portrait as portrait_mod
from .realtime import hub
from .export import to_docx, to_pdf
@@ -386,6 +387,7 @@ def my_characters(user: dict = Depends(current_user)):
@api.post("/characters")
def create_character(ch: dict = Body(...), user: dict = Depends(current_user)):
+ portrait_mod.clean(ch)
check_size(ch)
with db() as con:
if con.execute("SELECT COUNT(*) FROM characters WHERE owner_id=?", (user["id"],)).fetchone()[0] >= MAX_CHARACTERS:
@@ -437,6 +439,7 @@ def remove_share(cid: str, username: str, user: dict = Depends(current_user)):
@api.put("/characters/{cid}")
def update_character(cid: str, ch: dict = Body(...), user: dict = Depends(current_user)):
+ portrait_mod.clean(ch)
check_size(ch)
with db() as con:
_owned(con, "characters", cid, user)
@@ -533,7 +536,7 @@ def _card(data: str, rules: dict) -> dict:
prof = next((p for p in rules["professions"] if p["id"] == ch.get("profession")), None)
return {"name": ch.get("name") or "Nameless", "concept": str(ch.get("concept") or "")[:120],
"class": (cls or {}).get("name", ""), "subclass": (sub or {}).get("name", ""),
- "profession": (prof or {}).get("name", "")}
+ "profession": (prof or {}).get("name", ""), "portrait": portrait_mod.tag(ch.get("portrait"))}
@api.get("/lobbies")
@@ -590,6 +593,19 @@ def join_lobby(request: Request, body: dict = Body(...), user: dict = Depends(cu
return {"id": lobby["id"]}
+@api.get("/lobbies/{lid}/portrait/{username}")
+def lobby_portrait(lid: str, username: str, user: dict = Depends(current_user)):
+ """The portrait of a player's character in this lobby (icon on the map and in the player bar). Members only."""
+ with db() as con:
+ _lobby_for(con, lid, user)
+ row = con.execute("""SELECT c.data FROM lobby_members m JOIN users u ON u.id = m.user_id
+ JOIN characters c ON c.id = m.character_id WHERE m.lobby_id=? AND u.username=?""", (lid, username)).fetchone()
+ img = portrait_mod.image(json.loads(row["data"]).get("portrait")) if row else None
+ if not img:
+ raise HTTPException(404, "No portrait")
+ return Response(img, media_type="image/webp", headers={"Cache-Control": "private, max-age=86400"})
+
+
@api.get("/lobbies/{lid}")
def read_lobby(lid: str, user: dict = Depends(current_user)):
rules = load_rules()
diff --git a/backend/app/maps.py b/backend/app/maps.py
index 1ed6dfc..e039682 100644
--- a/backend/app/maps.py
+++ b/backend/app/maps.py
@@ -16,6 +16,7 @@ from fastapi.responses import Response
from PIL import Image, ImageOps
from . import vision
+from .portrait import tag as portrait_tag
MAX_UPLOAD = 12_000_000 # bytes of the uploaded file
MAX_SIDE = 4096 # larger images are scaled down
@@ -350,7 +351,13 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa
name = (json.loads(r["data"]).get("name") if r["data"] else "") or r["username"]
except ValueError:
name = r["username"]
- out.append({"id": r["id"], "kind": "player" if player else "npc", "username": r["username"], "name": name,
+ pt = ""
+ if player and r["data"]:
+ try:
+ pt = portrait_tag(json.loads(r["data"]).get("portrait"))
+ except ValueError:
+ pass
+ out.append({"id": r["id"], "kind": "player" if player else "npc", "username": r["username"], "name": name, "portrait": pt,
"x": r["x"], "y": r["y"], "size": r["size"], "hidden": bool(r["hidden"]),
"color": (r["mcolor"] or "#999999") if player else r["color"],
"can_move": ctx["is_dm"] or (r["user_id"] == user["id"] and not (ctx["los"] and r["x"] is None))})
diff --git a/backend/app/portrait.py b/backend/app/portrait.py
new file mode 100644
index 0000000..ce4f75c
--- /dev/null
+++ b/backend/app/portrait.py
@@ -0,0 +1,52 @@
+"""Character portraits: a small square picture stored inside the character as a data URL (so it travels with saving, sharing and
+loading). The browser crops it to a square; the server checks and re-encodes it (256 px, WebP) so only real images get stored."""
+import base64
+import binascii
+import hashlib
+import io
+
+from fastapi import HTTPException
+from PIL import Image, ImageOps
+
+SIZE = 256
+MAX_RAW = 400_000 # bytes of the uploaded data before re-encoding
+
+
+def clean(ch: dict) -> None:
+ """Validates and re-encodes ch["portrait"] in place (an empty value removes it)."""
+ p = ch.get("portrait")
+ if not p:
+ ch["portrait"] = ""
+ return
+ if not isinstance(p, str) or not p.startswith("data:image/") or ";base64," not in p:
+ raise HTTPException(422, "The portrait must be an image")
+ try:
+ raw = base64.b64decode(p.split(";base64,", 1)[1], validate=True)
+ if len(raw) > MAX_RAW:
+ raise HTTPException(413, "The portrait is too large")
+ im = Image.open(io.BytesIO(raw))
+ if im.format not in ("PNG", "JPEG", "WEBP"):
+ raise ValueError
+ im.load()
+ except HTTPException:
+ raise
+ except (binascii.Error, ValueError, OSError, Image.DecompressionBombError):
+ raise HTTPException(422, "The portrait must be a PNG, JPEG or WebP image")
+ im = ImageOps.fit(ImageOps.exif_transpose(im).convert("RGB"), (SIZE, SIZE))
+ out = io.BytesIO()
+ im.save(out, "WEBP", quality=85)
+ ch["portrait"] = "data:image/webp;base64," + base64.b64encode(out.getvalue()).decode()
+
+
+def tag(portrait) -> str:
+ """Short fingerprint, used as cache key in URLs ("" if there is no portrait)."""
+ return hashlib.sha1(portrait.encode()).hexdigest()[:10] if portrait else ""
+
+
+def image(portrait) -> bytes | None:
+ if not portrait or ";base64," not in portrait:
+ return None
+ try:
+ return base64.b64decode(portrait.split(";base64,", 1)[1])
+ except (binascii.Error, ValueError):
+ return None
diff --git a/frontend/src/Creator.jsx b/frontend/src/Creator.jsx
index 3bf9be0..fd98d83 100644
--- a/frontend/src/Creator.jsx
+++ b/frontend/src/Creator.jsx
@@ -1,3 +1,4 @@
+import PortraitEditor from './Portrait.jsx'
import { useEffect, useMemo, useRef, useState } from 'react'
import { applyPreset, applyProfession, compute, effectiveRules, emptyCharacter, kitItems, normalize, resetStep, setProfessionPicks } from './calc.js'
import SheetPanel from './SheetPanel.jsx'
@@ -167,6 +168,7 @@ export default function Creator({ rules: baseRules, user, loadRequest, clearLoad
})()}
{lobby.is_dm ? 'This player is not on the map yet. Switch to the full view and drag their token onto it.' : 'Waiting for the DM to put your token on the map.'}
}
diff --git a/frontend/src/MapView.jsx b/frontend/src/MapView.jsx
index 7a2f5bd..d925a48 100644
--- a/frontend/src/MapView.jsx
+++ b/frontend/src/MapView.jsx
@@ -6,7 +6,7 @@ const clamp = (v, a, b) => Math.min(b, Math.max(a, v))
// Map with pan (drag the background), zoom (wheel / pinch) and draggable tokens. Coordinates are image pixels.
// Everybody pans and zooms for themselves; token moves go out through onMove(id, x, y) which returns a promise.
-export function MapView({ map, image, tokens, onMove, selected, onSelect, grid: showGrid, tool, onClick, onHover, overlay, children }) {
+export function MapView({ map, image, portraitUrl, tokens, onMove, selected, onSelect, grid: showGrid, tool, onClick, onHover, overlay, children }) {
const box = useRef(null)
const [view, setView] = useState({ tx: 0, ty: 0, s: 1 })
const [drag, setDrag] = useState(null)
@@ -125,7 +125,7 @@ export function MapView({ map, image, tokens, onMove, selected, onSelect, grid: