Files

416 lines
14 KiB
Python

# -*- coding: utf-8 -*-
"""
Helper module for ingredient parsing, table structuring, and quantity scaling.
"""
import re
from typing import List, Dict, Any, Optional
COMMON_UNITS = [
"kg", "g", "mg", "l", "liter", "ml", "cl", "el", "tl", "esslöffel", "teelöffel",
"pck.", "pck", "päckchen", "pkt.", "pkt", "dose", "dosen", "glas", "gläser",
"prise", "prisen", "msp.", "msp", "messerspitze", "bund", "stk.", "stk",
"stück", "stücke", "zehe", "zehen", "zweig", "zweige", "blatt", "blätter",
"scheibe", "scheiben", "stange", "stangen", "kopf", "nest", "nester", "tropfen", "tr.",
"kästchen", "schale", "schalen", "topf", "töpfe", "packung", "packungen",
"becher", "beutel", "knolle", "knollen"
]
def parse_fraction(val_str: str) -> Optional[float]:
val_str = val_str.strip()
if "/" in val_str:
parts = val_str.split("/")
if len(parts) == 2:
try:
return float(parts[0]) / float(parts[1])
except ValueError:
pass
try:
return float(val_str.replace(",", "."))
except ValueError:
return None
def parse_ingredient_line(line: str) -> Dict[str, Any]:
raw = line.strip()
if not raw:
return {"type": "empty", "raw": ""}
# Check if section header (ends with colon or keywords)
lower = raw.lower()
if raw.endswith(":") or lower.startswith("für ") or lower in [
"teig", "sauce", "soße", "füllung", "belag", "creme", "glasur", "marinade", "deko",
"schichten & deko", "keksschicht", "heidelbeersoße", "zitronen-zimt-sirup", "zutaten",
"einlage & toppings", "gemüse & toppings", "lemon curd", "mascarponecreme"
]:
hdr_text = raw.rstrip(":")
return {
"type": "header",
"is_header": True,
"text": hdr_text,
"name": hdr_text,
"raw": raw
}
# Regex to match leading amount
match = re.match(r"^(\d+\s+\d+/\d+|\d+/\d+|\d+(?:[.,]\d+)?)(?:\s*-\s*(\d+(?:[.,]\d+)?))?\s*(.*)$", raw)
if match:
amt_str = match.group(1).strip()
range_end = match.group(2)
rest = match.group(3).strip()
if " " in amt_str and "/" in amt_str:
whole, frac = amt_str.split()
val = float(whole) + (parse_fraction(frac) or 0.0)
elif "/" in amt_str:
val = parse_fraction(amt_str) or 0.0
else:
val = parse_fraction(amt_str) or 0.0
display_amt = f"{amt_str}-{range_end}" if range_end else amt_str
rest_parts = rest.split(None, 1)
if rest_parts:
first_word_clean = rest_parts[0].lower().rstrip(".,")
if first_word_clean in COMMON_UNITS or rest_parts[0].lower() in COMMON_UNITS:
unit = rest_parts[0]
ing_name = rest_parts[1] if len(rest_parts) > 1 else ""
return {
"type": "ingredient",
"is_header": False,
"amount_val": val,
"amount": val,
"amount_str": display_amt,
"amount_formatted": display_amt,
"unit": unit,
"name": ing_name,
"raw": raw
}
return {
"type": "ingredient",
"is_header": False,
"amount_val": val,
"amount": val,
"amount_str": display_amt,
"amount_formatted": display_amt,
"unit": "",
"name": rest,
"raw": raw
}
return {
"type": "ingredient",
"is_header": False,
"amount_val": None,
"amount": None,
"amount_str": "",
"amount_formatted": "",
"unit": "",
"name": raw,
"raw": raw
}
def parse_ingredients_block(text: str) -> List[Dict[str, Any]]:
lines = [l.strip() for l in (text or "").split("\n") if l.strip()]
return [parse_ingredient_line(l) for l in lines]
def format_quantity_py(num: Optional[float]) -> str:
"""Formatiert eine Zahl lesbar für Rezepte (z. B. 0.5 -> ½, 1.5 -> 1 ½, 2.0 -> 2)."""
if num is None or num <= 0:
return ""
whole = int(num)
frac = round((num - whole) * 100) / 100.0
frac_str = ""
if 0.20 <= frac <= 0.30:
frac_str = "¼"
elif 0.31 <= frac <= 0.40:
frac_str = "⅓"
elif 0.45 <= frac <= 0.55:
frac_str = "½"
elif 0.63 <= frac <= 0.70:
frac_str = "⅔"
elif 0.72 <= frac <= 0.80:
frac_str = "¾"
if frac_str:
return f"{whole} {frac_str}".strip() if whole > 0 else frac_str
if abs(num - round(num)) < 0.05:
return str(int(round(num)))
# Deutsche Kommaschreibweise
val_str = f"{num:.1f}".rstrip("0").rstrip(".").replace(".", ",")
return val_str
def calculate_portion_matrix(
parsed_ingredients: List[Dict[str, Any]],
base_portions: int = 4,
targets: tuple = (2, 4, 6, 8)
) -> List[Dict[str, Any]]:
"""Berechnet für Waffeln / Pancakes etc. eine vorberechnete Tabelle für 2, 4, 6, 8 Portionen."""
if not base_portions or base_portions <= 0:
base_portions = 4
matrix = []
for item in parsed_ingredients:
if item.get("is_header"):
matrix.append({
"is_header": True,
"text": item.get("text") or item.get("name") or "",
"amounts": {p: "" for p in targets}
})
continue
row_amounts = {}
for p in targets:
amt_val = item.get("amount_val")
unit = item.get("unit") or ""
if amt_val is not None:
scaled_val = amt_val * (p / float(base_portions))
formatted_amt = format_quantity_py(scaled_val)
row_amounts[p] = f"{formatted_amt} {unit}".strip()
else:
row_amounts[p] = "—"
matrix.append({
"is_header": False,
"name": item.get("name") or item.get("raw") or "",
"raw": item.get("raw", ""),
"amounts": row_amounts
})
return matrix
SYNONYMS = {
"möhre": "karotte",
"möhren": "karotte",
"karotte": "karotte",
"karotten": "karotte",
"zwiebel": "zwiebel",
"zwiebeln": "zwiebel",
"knoblauch": "knoblauch",
"knoblauchzehe": "knoblauch",
"knoblauchzehen": "knoblauch",
"kartoffel": "kartoffel",
"kartoffeln": "kartoffel",
"erdapfel": "kartoffel",
"erdäpfel": "kartoffel",
"ei": "ei",
"eier": "ei",
"tomate": "tomate",
"tomaten": "tomate",
"paprika": "paprika",
"paprikas": "paprika",
"paprikaschote": "paprika",
"paprikaschoten": "paprika",
"apfel": "apfel",
"äpfel": "apfel",
"birne": "birne",
"birnen": "birne",
"zitrone": "zitrone",
"zitronen": "zitrone",
"limette": "limette",
"limetten": "limette",
"gurke": "gurke",
"gurken": "gurke",
"salatgurke": "gurke",
"salatgurken": "gurke",
"avocado": "avocado",
"avocados": "avocado",
"champignon": "champignon",
"champignons": "champignon",
"pilz": "champignon",
"pilze": "champignon",
"frühlingszwiebel": "frühlingszwiebel",
"frühlingszwiebeln": "frühlingszwiebel",
"lauchzwiebel": "frühlingszwiebel",
"lauchzwiebeln": "frühlingszwiebel",
"lauch": "porree",
"porree": "porree",
"lauchstange": "porree",
"lauchstangen": "porree",
"schalotte": "schalotte",
"schalotten": "schalotte",
"banane": "banane",
"bananen": "banane",
"orange": "orange",
"orangen": "orange",
"chili": "chili",
"chilischote": "chili",
"chilischoten": "chili",
"chilis": "chili",
"semmel": "brötchen",
"semmeln": "brötchen",
"brötchen": "brötchen",
}
def clean_ingredient_name(name: str) -> str:
"""Bereinigt Zutatennamen von vorangestellten Aufzählungszeichen, Klammerzusätzen und Zubereitungshinweisen."""
if not name:
return ""
# Führende Bullets, Striche, Sternchen entfernen
s = re.sub(r"^[•\-\*\s]+", "", name.strip())
# Klammerzusätze wie '(in Scheiben)', '(optional)', '(TK)', '(Größe M)' entfernen
s = re.sub(r"\s*\([^)]*\)", "", s)
# Nachgestellte Zubereitungshinweise nach Komma entfernen, falls kurz (z.B. 'Zwiebeln, gewürfelt')
if "," in s:
parts = s.split(",", 1)
if len(parts[1].strip().split()) <= 4:
s = parts[0]
# Satzzeichen am Ende entfernen
s = re.sub(r"[\s.,;:]+$", "", s).strip()
s = re.sub(r"\s+", " ", s)
if s and not s[0].isupper():
return s[0].upper() + s[1:]
return s
def get_ingredient_key(name: str) -> str:
"""Gibt einen normalisierten Schlüssel für den Zutatenvergleich (Plural, Synonyme, Kleinschreibung) zurück."""
clean = re.sub(r"^[•\-\*\s]+", "", (name or "").strip().lower())
clean = re.sub(r"\s*\([^)]*\)", "", clean)
if "," in clean:
parts = clean.split(",", 1)
if len(parts[1].strip().split()) <= 4:
clean = parts[0]
clean = re.sub(r"[\s.,;:]+$", "", clean).strip()
clean = re.sub(r"\s+", " ", clean)
if not clean:
return ""
if clean in SYNONYMS:
return SYNONYMS[clean]
words = clean.split()
if len(words) > 1 and words[-1] in SYNONYMS:
return " ".join(words[:-1] + [SYNONYMS[words[-1]]])
# Allgemeine deutsche Pluralformen für Endungen (-n / -en)
if clean.endswith("n") and len(clean) > 4 and not clean.endswith("in"):
if clean.endswith("en"):
stem = clean[:-1]
if stem.endswith("e"):
return stem
return clean[:-1]
if clean.endswith("s") and len(clean) > 4:
return clean[:-1]
return clean
def format_amount_display(num: float) -> str:
"""Formatiert Gleitkommazahlen sauber für Mengenangaben (z.B. 1.5 -> '1,5', 2.0 -> '2')."""
if abs(num - round(num)) < 0.01:
return str(int(round(num)))
s = f"{num:.2f}".rstrip("0").rstrip(".")
return s.replace(".", ",")
def normalize_unit(u: str) -> str:
"""Normalisiert Kücheneinheiten in einheitliche Bezeichnungen."""
if not u:
return ""
u_clean = u.strip().lower().rstrip(".")
if u_clean in ["kg", "kilo", "kilogramm"]:
return "kg"
if u_clean in ["g", "gramm", "gr"]:
return "g"
if u_clean in ["mg", "milligramm"]:
return "mg"
if u_clean in ["l", "liter"]:
return "l"
if u_clean in ["ml", "milliliter"]:
return "ml"
if u_clean in ["cl"]:
return "cl"
if u_clean in ["el", "esslöffel", "eßlöffel"]:
return "EL"
if u_clean in ["tl", "teelöffel"]:
return "TL"
if u_clean in ["stk", "stück", "stücke"]:
return "Stück"
if u_clean in ["dose", "dosen"]:
return "Dose"
if u_clean in ["glas", "gläser"]:
return "Glas"
if u_clean in ["pck", "päckchen", "pkg", "packung", "packungen"]:
return "Pck."
if u_clean in ["prise", "prisen"]:
return "Prise"
if u_clean in ["zehe", "zehen"]:
return "Zehe"
if u_clean in ["bund"]:
return "Bund"
if u_clean in ["stange", "stangen"]:
return "Stange"
if u_clean in ["becher"]:
return "Becher"
if u_clean in ["kästchen"]:
return "Kästchen"
if u_clean in ["schale", "schalen"]:
return "Schale"
if u_clean in ["topf", "töpfe"]:
return "Topf"
return u.strip()
def add_ingredient_amounts(amt1_str: str, u1: str, amt2_str: str, u2: str) -> tuple:
"""
Addiert zwei Mengenangaben intelligent unter Berücksichtigung von Einheiten (Gewichte, Volumina, Stück).
Gibt (neue_menge, neue_einheit) zurück.
"""
n1 = parse_fraction(amt1_str) if amt1_str else None
n2 = parse_fraction(amt2_str) if amt2_str else None
norm_u1 = normalize_unit(u1)
norm_u2 = normalize_unit(u2)
# Wenn keines von beiden eine Zahl hat
if n1 is None and n2 is None:
return ("", norm_u1 or norm_u2)
# Wenn nur einer eine Zahl hat
if n1 is None:
return (format_amount_display(n2) if n2 is not None else amt2_str, norm_u2)
if n2 is None:
return (format_amount_display(n1) if n1 is not None else amt1_str, norm_u1)
# Gewichte (g, kg)
if norm_u1 in ["g", "kg"] and norm_u2 in ["g", "kg"]:
g1 = n1 * 1000.0 if norm_u1 == "kg" else n1
g2 = n2 * 1000.0 if norm_u2 == "kg" else n2
total_g = g1 + g2
if total_g >= 1000.0 and (norm_u1 == "kg" or norm_u2 == "kg" or total_g % 500 == 0):
return (format_amount_display(total_g / 1000.0), "kg")
return (format_amount_display(total_g), "g")
# Volumina (ml, l, cl)
if norm_u1 in ["ml", "l", "cl"] and norm_u2 in ["ml", "l", "cl"]:
ml1 = n1 * 1000.0 if norm_u1 == "l" else (n1 * 10.0 if norm_u1 == "cl" else n1)
ml2 = n2 * 1000.0 if norm_u2 == "l" else (n2 * 10.0 if norm_u2 == "cl" else n2)
total_ml = ml1 + ml2
if total_ml >= 1000.0 and (norm_u1 == "l" or norm_u2 == "l" or total_ml % 500 == 0):
return (format_amount_display(total_ml / 1000.0), "l")
return (format_amount_display(total_ml), "ml")
# Identische Einheiten oder Stückzahlen/keine Einheit
if norm_u1.lower() == norm_u2.lower() or (not norm_u1 and not norm_u2):
total = n1 + n2
out_u = norm_u1 or norm_u2
if out_u == "Dose" and total > 1: out_u = "Dosen"
elif out_u == "Glas" and total > 1: out_u = "Gläser"
elif out_u == "Zehe" and total > 1: out_u = "Zehen"
elif out_u == "Stange" and total > 1: out_u = "Stangen"
elif out_u == "Prise" and total > 1: out_u = "Prisen"
elif out_u == "Schale" and total > 1: out_u = "Schalen"
elif out_u == "Topf" and total > 1: out_u = "Töpfe"
return (format_amount_display(total), out_u)
# Inkompatible Einheiten lesbar zusammenführen (z.B. '1 Bund + 500 g')
part1 = f"{format_amount_display(n1)} {u1}".strip()
part2 = f"{format_amount_display(n2)} {u2}".strip()
return (f"{part1} + {part2}", "")