"""Modèle de données du mini-jeu Mots mêlés (voir docs/plan/PLAN.md §3.2) — l'apprenant retrouve chaque mot caché dans une grille de lettres, placé horizontalement, verticalement, ou en diagonale (haut-gauche vers bas-droite, ou haut-droite vers bas-gauche), jamais à l'envers ni en diagonale inversée (voir document_engine/rendering/render_document_element.py ::_render_mots_player, qui construit la grille elle-même). Même convention resolve_X/sanitize_X que quiz_config.py/association_config.py/ memory_config.py (aucun import croisé).""" import unicodedata from typing import Any MIN_WORDS = 5 MAX_WORDS = 10 MIN_WORD_LENGTH = 2 MAX_WORD_LENGTH = 20 DEFAULT_MOTS_CONFIG: dict[str, Any] = { "theme_color": "#ff5f2e", "words": [], } def _sanitize_word(raw: Any) -> str | None: """None si, une fois nettoyé, il ne reste aucune lettre exploitable — filtré par sanitize_mots_config plutôt que de faire échouer toute la grille, même convention que quiz_config.py::_sanitize_question. Les accents sont retirés (décomposition NFKD puis filtrage ASCII) : deux mots qui se croisent sur une même case doivent pouvoir partager exactement la même lettre, ce qu'un "É" et un "E" ne permettraient pas.""" if not isinstance(raw, str): return None decomposed = unicodedata.normalize("NFKD", raw.upper()) letters = "".join(ch for ch in decomposed if ch.isalpha() and ch.isascii()) if len(letters) < MIN_WORD_LENGTH: return None return letters[:MAX_WORD_LENGTH] def sanitize_mots_config(raw_config: Any) -> dict[str, Any]: config = dict(DEFAULT_MOTS_CONFIG) if not isinstance(raw_config, dict): return config theme_color = raw_config.get("theme_color") if isinstance(theme_color, str) and theme_color: config["theme_color"] = theme_color raw_words = raw_config.get("words") if isinstance(raw_words, list): words: list[str] = [] seen: set[str] = set() for item in raw_words: word = _sanitize_word(item) if word is None or word in seen: continue seen.add(word) words.append(word) config["words"] = words[:MAX_WORDS] return config