Files
ShooterHub/analyzer/dope_card.py
2026-03-19 16:42:37 +01:00

144 lines
5.3 KiB
Python

"""Generate a printable PRS dope card as PDF (A4 portrait)."""
from fpdf import FPDF
# Column widths in mm
_W = {
"num": 10,
"name": 28,
"dist": 20,
"time": 18,
"pos": 26,
"dope_e": 30,
"dope_w": 30,
"hits": 24,
"notes": 0, # fills remaining width
}
_ROW_H = 8
_HEAD_H = 9
_DARK = (26, 26, 46) # #1a1a2e
_LIGHT = (240, 244, 255) # #f0f4ff
_GRID = (200, 210, 230)
def _notes_w(epw: float) -> float:
fixed = sum(v for k, v in _W.items() if k != "notes")
return max(0, epw - fixed)
def generate_dope_card(session, stages: list) -> bytes:
pdf = FPDF(orientation="P", unit="mm", format="A4")
pdf.set_auto_page_break(auto=True, margin=15)
pdf.add_page()
pdf.set_margins(10, 12, 10)
epw = pdf.w - pdf.l_margin - pdf.r_margin
nw = _notes_w(epw)
# ── Header ──────────────────────────────────────────────────────────────
pdf.set_font("Helvetica", "B", 18)
pdf.set_text_color(*_DARK)
pdf.cell(0, 10, "FICHE DE TIR — PRS", new_x="LMARGIN", new_y="NEXT", align="C")
pdf.set_font("Helvetica", "", 9)
pdf.set_text_color(80, 80, 80)
parts = [session.session_date.strftime("%d/%m/%Y")]
if session.location_name:
parts.append(session.location_name)
if session.rifle:
parts.append(session.rifle.name)
if session.rifle.caliber:
parts.append(session.rifle.caliber)
if session.ammo_brand:
parts.append(session.ammo_brand)
if session.ammo_weight_gr:
parts.append(f"{session.ammo_weight_gr} gr")
pdf.cell(0, 5, " | ".join(parts), new_x="LMARGIN", new_y="NEXT", align="C")
pdf.ln(4)
# ── Column headers ───────────────────────────────────────────────────────
pdf.set_fill_color(*_DARK)
pdf.set_text_color(255, 255, 255)
pdf.set_font("Helvetica", "B", 8)
headers = [
("", _W["num"]),
("Nom", _W["name"]),
("Dist.(m)", _W["dist"]),
("Temps(s)", _W["time"]),
("Position", _W["pos"]),
("Dope Élév.", _W["dope_e"]),
("Dope Dérive", _W["dope_w"]),
("Coups/Poss.", _W["hits"]),
("Notes", nw),
]
for label, w in headers:
pdf.cell(w, _HEAD_H, label, border=0, fill=True, align="C")
pdf.ln()
# ── Stage rows ───────────────────────────────────────────────────────────
pdf.set_text_color(30, 30, 30)
pdf.set_font("Helvetica", "", 9)
for i, st in enumerate(stages):
fill = i % 2 == 0
pdf.set_fill_color(*(_LIGHT if fill else (255, 255, 255)))
pdf.set_draw_color(*_GRID)
hits_str = ""
if st.get("hits") is not None:
hits_str = str(st["hits"])
if st.get("possible"):
hits_str += f"/{st['possible']}"
elif st.get("possible"):
hits_str = f"—/{st['possible']}"
row = [
(str(st.get("num", i + 1)), _W["num"], "C"),
(st.get("name") or "", _W["name"], "L"),
(str(st.get("distance_m") or ""), _W["dist"], "C"),
(str(st.get("time_s") or ""), _W["time"], "C"),
(_pos_label(st.get("position", "")), _W["pos"], "L"),
(st.get("dope_elevation") or "", _W["dope_e"], "C"),
(st.get("dope_windage") or "", _W["dope_w"], "C"),
(hits_str, _W["hits"], "C"),
(st.get("notes") or "", nw, "L"),
]
for val, w, align in row:
pdf.cell(w, _ROW_H, val, border="B", fill=fill, align=align)
pdf.ln()
# ── Blank rows for hand-written stages ──────────────────────────────────
spare = max(0, 10 - len(stages))
for i in range(min(spare, 5)):
fill = (len(stages) + i) % 2 == 0
pdf.set_fill_color(*(_LIGHT if fill else (255, 255, 255)))
for _, w, _ in row: # reuse last row widths
pdf.cell(w, _ROW_H, "", border="B", fill=fill)
pdf.ln()
# ── Footer ───────────────────────────────────────────────────────────────
pdf.ln(4)
pdf.set_font("Helvetica", "I", 7)
pdf.set_text_color(160, 160, 160)
pdf.cell(0, 5, "The Shooter's Network — fiche générée automatiquement",
new_x="LMARGIN", new_y="NEXT", align="C")
return bytes(pdf.output())
_POSITION_LABELS = {
"prone": "Couché",
"standing": "Debout",
"kneeling": "Agenouillé",
"sitting": "Assis",
"barricade": "Barricade",
"rooftop": "Toit",
"unknown": "Variable",
}
def _pos_label(slug: str) -> str:
return _POSITION_LABELS.get(slug, slug.replace("_", " ").title() if slug else "")