# -*- 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}", "")