416 lines
14 KiB
Python
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}", "")
|
|
|
|
|