Fix: En-Dash in PDF durch Bindestrich ersetzen + _s() Sanitizer für alle Texteingaben (SW by-v982)

This commit is contained in:
rene 2026-05-15 15:50:02 +02:00
parent 1a8716b0b2
commit 68fd9c0e38
7 changed files with 28 additions and 16 deletions

View file

@ -408,7 +408,7 @@ async def serve_media(path: str, request: _Request):
raise _HE(404, "Nicht gefunden.") raise _HE(404, "Nicht gefunden.")
return _media_response(filepath) return _media_response(filepath)
APP_VER = "981" # muss mit APP_VER in app.js übereinstimmen APP_VER = "982" # muss mit APP_VER in app.js übereinstimmen
@app.get("/.well-known/assetlinks.json") @app.get("/.well-known/assetlinks.json")
async def assetlinks(): async def assetlinks():

View file

@ -1339,7 +1339,7 @@ async def _handle_upgrade_invoices(req: dict, new_tier_label: str):
price = {"pro": 29.00, "breeder": 49.00}.get(tier, 29.00) price = {"pro": 29.00, "breeder": 49.00}.get(tier, 29.00)
today = datetime.now(_TZ).date() today = datetime.now(_TZ).date()
end_date = today.replace(year=today.year + 1) - timedelta(days=1) end_date = today.replace(year=today.year + 1) - timedelta(days=1)
period = f"{today.strftime('%d.%m.%Y')} {end_date.strftime('%d.%m.%Y')}" period = f"{today.strftime('%d.%m.%Y')} - {end_date.strftime('%d.%m.%Y')}"
description = f"{new_tier_label} Jahresabo" description = f"{new_tier_label} Jahresabo"
billing = conn.execute( billing = conn.execute(
@ -1486,7 +1486,7 @@ async def send_quarterly_report(data: QuarterlyReportBody, user=Depends(require_
count_sent = sum(1 for r in rows if r["status"] == "sent") count_sent = sum(1 for r in rows if r["status"] == "sent")
subject_stb = ( subject_stb = (
f"Ban Yaro Rechnungen Q{data.quarter}/{data.year} " f"Ban Yaro - Rechnungen Q{data.quarter}/{data.year} "
f"({start} bis {end})" f"({start} bis {end})"
) )
body_stb = ( body_stb = (
@ -1559,7 +1559,7 @@ async def send_quarterly_report(data: QuarterlyReportBody, user=Depends(require_
"sent_to": data.email, "sent_to": data.email,
"year": data.year, "year": data.year,
"quarter": data.quarter, "quarter": data.quarter,
"period": f"{start} {end}", "period": f"{start} - {end}",
"count": len(rows), "count": len(rows),
"count_paid": count_paid, "count_paid": count_paid,
"count_sent": count_sent, "count_sent": count_sent,

View file

@ -82,6 +82,18 @@ def _generate_pdf(invoice, items) -> bytes:
LG = (245, 245, 245) LG = (245, 245, 245)
WH = (255, 255, 255) WH = (255, 255, 255)
def _s(text) -> str:
"""Nicht-Latin1-Zeichen ersetzen bevor sie an fpdf Helvetica übergeben werden."""
if not text:
return ""
return (str(text)
.replace("", "-").replace("", "-") # En/Em-Dash
.replace("", "'").replace("", "'") # Typogr. Anf.zeichen
.replace("", '"').replace("", '"')
.replace("", "...").replace("·", ".")
.replace("", "EUR") # € falls doch
)
def eur(v: float) -> str: def eur(v: float) -> str:
s = f"{v:,.2f}".replace(",", "X").replace(".", ",").replace("X", ".") s = f"{v:,.2f}".replace(",", "X").replace(".", ",").replace("X", ".")
return f"{s} EUR" return f"{s} EUR"
@ -144,17 +156,17 @@ def _generate_pdf(invoice, items) -> bytes:
pdf.set_xy(20, 52) pdf.set_xy(20, 52)
pdf.set_font("Helvetica", "B", 10) pdf.set_font("Helvetica", "B", 10)
pdf.set_text_color(*DK) pdf.set_text_color(*DK)
pdf.cell(85, 5.5, invoice["recipient_name"], new_x="LMARGIN", new_y="NEXT") pdf.cell(85, 5.5, _s(invoice["recipient_name"]), new_x="LMARGIN", new_y="NEXT")
pdf.set_font("Helvetica", "", 10) pdf.set_font("Helvetica", "", 10)
if invoice.get("recipient_address"): if invoice.get("recipient_address"):
for line in str(invoice["recipient_address"]).split("\n"): for line in str(invoice["recipient_address"]).split("\n"):
if line.strip(): if line.strip():
pdf.set_x(20) pdf.set_x(20)
pdf.cell(85, 5, line.strip(), new_x="LMARGIN", new_y="NEXT") pdf.cell(85, 5, _s(line.strip()), new_x="LMARGIN", new_y="NEXT")
pdf.set_x(20) pdf.set_x(20)
pdf.set_font("Helvetica", "", 8.5) pdf.set_font("Helvetica", "", 8.5)
pdf.set_text_color(*GY) pdf.set_text_color(*GY)
pdf.cell(85, 5, invoice["recipient_email"]) pdf.cell(85, 5, _s(invoice["recipient_email"]))
# ── Info-Block rechts ───────────────────────────────────────── # ── Info-Block rechts ─────────────────────────────────────────
info_rows = [ info_rows = [
@ -163,7 +175,7 @@ def _generate_pdf(invoice, items) -> bytes:
("Fällig bis", due_date), ("Fällig bis", due_date),
] ]
if invoice.get("service_period"): if invoice.get("service_period"):
info_rows.append(("Leistungszeitraum", invoice["service_period"])) info_rows.append(("Leistungszeitraum", _s(invoice["service_period"])))
y_info = 52 y_info = 52
for lbl, val in info_rows: for lbl, val in info_rows:
@ -204,7 +216,7 @@ def _generate_pdf(invoice, items) -> bytes:
for i, item in enumerate(items): for i, item in enumerate(items):
pdf.set_fill_color(*(LG if i % 2 == 0 else WH)) pdf.set_fill_color(*(LG if i % 2 == 0 else WH))
qty = f"{item['quantity']:.2f}".rstrip("0").rstrip(".") qty = f"{item['quantity']:.2f}".rstrip("0").rstrip(".")
pdf.cell(CW[0], 7, f" {str(item['description'])[:64]}", border="B", fill=True) pdf.cell(CW[0], 7, f" {_s(str(item['description']))[:64]}", border="B", fill=True)
pdf.cell(CW[1], 7, qty, border="B", fill=True, align="C") pdf.cell(CW[1], 7, qty, border="B", fill=True, align="C")
pdf.cell(CW[2], 7, eur(item["unit_price"]), border="B", fill=True, align="R") pdf.cell(CW[2], 7, eur(item["unit_price"]), border="B", fill=True, align="R")
pdf.cell(CW[3], 7, eur(item["total"]), border="B", fill=True, align="R", pdf.cell(CW[3], 7, eur(item["total"]), border="B", fill=True, align="R",
@ -240,7 +252,7 @@ def _generate_pdf(invoice, items) -> bytes:
pdf.set_x(20) pdf.set_x(20)
pdf.set_font("Helvetica", "I", 8.5) pdf.set_font("Helvetica", "I", 8.5)
pdf.set_text_color(*GY) pdf.set_text_color(*GY)
pdf.multi_cell(W, 5, "Hinweis: Gem. § 19 UStG wird keine Umsatzsteuer berechnet.") pdf.multi_cell(W, 5, _s("Hinweis: Gem. § 19 UStG wird keine Umsatzsteuer berechnet.")
# ── Zahlungsinfo-Box ────────────────────────────────────────── # ── Zahlungsinfo-Box ──────────────────────────────────────────
pdf.ln(5) pdf.ln(5)
@ -276,7 +288,7 @@ def _generate_pdf(invoice, items) -> bytes:
pdf.set_x(20) pdf.set_x(20)
pdf.set_font("Helvetica", "I", 9) pdf.set_font("Helvetica", "I", 9)
pdf.set_text_color(*GY) pdf.set_text_color(*GY)
pdf.multi_cell(W, 5, str(invoice["notes"])) pdf.multi_cell(W, 5, _s(str(invoice["notes"])))
# ── Footer (fixiert auf Seite 1, kein auto-break) ───────────── # ── Footer (fixiert auf Seite 1, kein auto-break) ─────────────
pdf.set_auto_page_break(False) pdf.set_auto_page_break(False)
@ -410,7 +422,7 @@ def get_quarterly(year: int, q: int, admin=Depends(require_admin)):
labels = {1: "01.01.", 2: "01.04.", 3: "01.07.", 4: "01.10."} labels = {1: "01.01.", 2: "01.04.", 3: "01.07.", 4: "01.10."}
ends = {1: "31.03.", 2: "30.06.", 3: "30.09.", 4: "31.12."} ends = {1: "31.03.", 2: "30.06.", 3: "30.09.", 4: "31.12."}
period = f"Q{q} {year} ({labels[q]} {ends[q]})" period = f"Q{q} {year} ({labels[q]} - {ends[q]})"
with db() as conn: with db() as conn:
# Alle Rechnungen außer Entwürfe im Quartal (nach Ausstellungsdatum) # Alle Rechnungen außer Entwürfe im Quartal (nach Ausstellungsdatum)

View file

@ -226,7 +226,7 @@ async def _create_renewal_invoice_draft(user: dict, expires: date, tier_label: s
# Verlängerungszeitraum: Folgetag nach Ablauf bis +1 Jahr # Verlängerungszeitraum: Folgetag nach Ablauf bis +1 Jahr
start = expires + timedelta(days=1) start = expires + timedelta(days=1)
end = start.replace(year=start.year + 1) - timedelta(days=1) end = start.replace(year=start.year + 1) - timedelta(days=1)
period = f"{start.strftime('%d.%m.%Y')} {end.strftime('%d.%m.%Y')}" period = f"{start.strftime('%d.%m.%Y')} - {end.strftime('%d.%m.%Y')}"
with db() as conn: with db() as conn:
# Nur anlegen wenn noch kein Entwurf/offener Eintrag für diesen User + Zeitraum # Nur anlegen wenn noch kein Entwurf/offener Eintrag für diesen User + Zeitraum

View file

@ -3,7 +3,7 @@
Router, State-Management, Navigation, Initialisierung. Router, State-Management, Navigation, Initialisierung.
============================================================ */ ============================================================ */
const APP_VER = '981'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VER = '982'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VERSION = '1.6.0'; // ← semantische Version, wird bei make release gesetzt const APP_VERSION = '1.6.0'; // ← semantische Version, wird bei make release gesetzt
const IS_STAGING = location.hostname === 'staging.banyaro.app'; const IS_STAGING = location.hostname === 'staging.banyaro.app';
// Cache-Bust-Parameter nach Update-Reload sofort entfernen // Cache-Bust-Parameter nach Update-Reload sofort entfernen

View file

@ -3646,7 +3646,7 @@ window.Page_admin = (() => {
const _now = new Date(); const _now = new Date();
const _end = new Date(_now.getFullYear() + 1, _now.getMonth(), _now.getDate() - 1); const _end = new Date(_now.getFullYear() + 1, _now.getMonth(), _now.getDate() - 1);
const _fmt = d => `${String(d.getDate()).padStart(2,'0')}.${String(d.getMonth()+1).padStart(2,'0')}.${d.getFullYear()}`; const _fmt = d => `${String(d.getDate()).padStart(2,'0')}.${String(d.getMonth()+1).padStart(2,'0')}.${d.getFullYear()}`;
const _period = `${_fmt(_now)} ${_fmt(_end)}`; const _period = `${_fmt(_now)} - ${_fmt(_end)}`;
function _discountNote(reason, count, pct, tierLabel) { function _discountNote(reason, count, pct, tierLabel) {
const agb = 'Jahresbeitrag gem. AGB. Bei vorzeitiger Kündigung keine anteilige Rückerstattung; Zugang bleibt bis Laufzeitende bestehen.'; const agb = 'Jahresbeitrag gem. AGB. Bei vorzeitiger Kündigung keine anteilige Rückerstattung; Zugang bleibt bis Laufzeitende bestehen.';

View file

@ -3,7 +3,7 @@
Offline-Cache + Push Notifications + Tile-Cache Offline-Cache + Push Notifications + Tile-Cache
============================================================ */ ============================================================ */
const CACHE_VERSION = 'by-v981'; const CACHE_VERSION = 'by-v982';
const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_STATIC = `${CACHE_VERSION}-static`;
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache