Build and deploy / test-python (push) Failing after 1m15s
Build and deploy / test-js (push) Successful in 54s
Build and deploy / lint-python (push) Failing after 1m12s
Build and deploy / lint-js (push) Failing after 1m8s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Build and deploy / sonarqube (push) Failing after 1m4s
Retour utilisateur : "une consequence peut mener a d'autres choix et
ainsi de suite, il n'y a pas de notion vrai/faux, il faut une modale
avec la possibilite de construire un veritable arbre de choix
consequence". Remplace le modele plat (situation + 2-4 choix + un seul
bon choix + une consequence terminale) par un vrai graphe de nœuds :
{"title", "nodes": [{"id", "text", "choices": [{"text", "target_id"}]}]}
- nodes[0] est la situation initiale, chaque choix peut pointer vers
n'importe quel autre nœud (branchement, convergence, fins multiples),
un nœud sans choix est une fin de branche valide. Aucune notion de
bonne/mauvaise reponse.
L'arbre se construit desormais dans une modale dediee (trop de
structure pour la colonne etroite du panneau Proprietes) : liste de
nœuds, chaque choix avec un menu deroulant "mene a" listant les autres
nœuds ou "fin de branche". La modale est un composant generique
(.docModal*) independant de tout framework externe.
sanitize_scenario_config degrade silencieusement tout target_id
orphelin (nœud supprime) vers None plutot que de faire echouer le
scenario entier. Le lecteur cote client navigue le graphe nœud par
nœud, le texte du nœud visite remplace le precedent (toujours pas un
Quiz), jusqu'a une fin de branche puis passage au scenario suivant.
Verifie via simulation DOM reelle (jsdom) : navigation ramifiee
(branchement, convergence, fin via nœud vide ET via choix sans cible),
plusieurs arbres a la suite, et l'editeur modal complet (ouverture,
ajout/suppression de nœud avec reparation des references pendantes,
changement de cible, fermeture bouton/fond/Echap).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
548 lines
26 KiB
Python
548 lines
26 KiB
Python
import html as html_lib
|
|
import json
|
|
import random
|
|
from typing import Any
|
|
|
|
_SHAPE_TAGS = {"rectangle": "rect", "cercle": "circle", "trait": "line"}
|
|
|
|
|
|
def render_document(elements: list[dict[str, Any]]) -> str:
|
|
"""Assemble le document ENTIER à partir de la liste à plat renvoyée
|
|
par document_engine.list_document_elements : les éléments top-niveau
|
|
(parent_id NULL) dans l'ordre, chaque rangée ("row") rendant
|
|
récursivement ses propres enfants côte à côte. Point d'entrée unique
|
|
du rendu, utilisé à la fois par le canevas d'édition et par le Mode
|
|
Aperçu (même fonction, voir docs/plan/PLAN.md — "Aperçu réel")."""
|
|
children_by_parent: dict[int | None, list[dict[str, Any]]] = {}
|
|
for el in elements:
|
|
children_by_parent.setdefault(el["parent_id"], []).append(el)
|
|
top_level = children_by_parent.get(None, [])
|
|
return "".join(render_document_element(el, children_by_parent) for el in top_level)
|
|
|
|
|
|
def render_document_element(el: dict[str, Any], children_by_parent: dict[int | None, list[dict[str, Any]]]) -> str:
|
|
"""HTML d'UN élément — dispatch par dict plutôt qu'un enchaînement de
|
|
`if` (mirroir de l'esprit de game_engine/scenes/render_scene_object.py,
|
|
mais un vrai registre ici puisque le nombre de kinds est plus élevé)."""
|
|
renderer = _RENDERERS.get(el["kind"], _render_unknown)
|
|
return renderer(el, children_by_parent)
|
|
|
|
|
|
def _render_row(el: dict[str, Any], children_by_parent: dict[int | None, list[dict[str, Any]]]) -> str:
|
|
attrs = el["attributes"]
|
|
gap = attrs.get("gap", 16)
|
|
align = html_lib.escape(str(attrs.get("align", "stretch")))
|
|
justify = html_lib.escape(str(attrs.get("justify", "flex-start")))
|
|
style = f"display:flex; flex-wrap:wrap; gap:{gap}px; align-items:{align}; justify-content:{justify};"
|
|
children = children_by_parent.get(el["id"], [])
|
|
inner = "".join(render_document_element(child, children_by_parent) for child in children)
|
|
return f'<div class="docRow" data-element-id="{el["id"]}" data-kind="row" style="{style}">{inner}</div>'
|
|
|
|
|
|
def _render_shape(el: dict[str, Any], _children_by_parent: dict[int | None, list[dict[str, Any]]]) -> str:
|
|
a = el["attributes"]
|
|
x, y, width, height = a["x"], a["y"], a["width"], a["height"]
|
|
rotation, z_index = a.get("rotation", 0), a.get("z_index", 1)
|
|
fill = html_lib.escape(str(a.get("fill", "#ff5f2e")))
|
|
stroke = html_lib.escape(str(a.get("stroke", "#232a38")))
|
|
stroke_width = a.get("stroke_width", 2)
|
|
label = html_lib.escape(str(a.get("label", "")))
|
|
wrap_style = (
|
|
f"position:absolute; left:{x}px; top:{y}px; width:{width}px; height:{height}px; "
|
|
f"transform:rotate({rotation}deg); z-index:{z_index};"
|
|
)
|
|
kind = el["kind"]
|
|
stroke_attrs = f'stroke="{stroke}" stroke-width="{stroke_width}"'
|
|
if kind == "triangle":
|
|
points = f"{width / 2},0 {width},{height} 0,{height}"
|
|
shape_svg = f'<polygon points="{points}" fill="{fill}" {stroke_attrs}></polygon>'
|
|
else:
|
|
tag = _SHAPE_TAGS.get(kind, "rect")
|
|
if tag == "circle":
|
|
cx, cy, r = width / 2, height / 2, min(width, height) / 2
|
|
shape_svg = f'<circle cx="{cx}" cy="{cy}" r="{r}" fill="{fill}" {stroke_attrs}></circle>'
|
|
elif tag == "line":
|
|
shape_svg = f'<line x1="0" y1="{height / 2}" x2="{width}" y2="{height / 2}" {stroke_attrs}></line>'
|
|
else:
|
|
shape_svg = f'<rect width="{width}" height="{height}" fill="{fill}" {stroke_attrs}></rect>'
|
|
label_attr = f' aria-label="{label}"' if label else ' aria-hidden="true"'
|
|
return (
|
|
f'<div class="docShape" data-element-id="{el["id"]}" data-kind="{kind}" style="{wrap_style}"{label_attr}>'
|
|
f'<svg width="{width}" height="{height}" viewBox="0 0 {width} {height}">{shape_svg}</svg>'
|
|
f"</div>"
|
|
)
|
|
|
|
|
|
_STYLE_PRESETS = {
|
|
"titre1": ("clamp(1.6rem,4vw,2rem)", 800, 1.15),
|
|
"titre2": ("1.3rem", 800, 1.25),
|
|
"paragraphe": ("15px", 400, 1.5),
|
|
"legende": ("12.5px", 600, 1.4),
|
|
}
|
|
|
|
|
|
def _render_text(el: dict[str, Any], _children_by_parent: dict[int | None, list[dict[str, Any]]]) -> str:
|
|
a = el["attributes"]
|
|
content = html_lib.escape(str(a.get("content", "")))
|
|
style_name = a.get("style", "paragraphe")
|
|
font_size, base_weight, line_height = _STYLE_PRESETS.get(style_name, _STYLE_PRESETS["paragraphe"])
|
|
weight = 800 if a.get("bold") else base_weight
|
|
font_style = "italic" if a.get("italic") else "normal"
|
|
text_decoration = "underline" if a.get("underline") else "none"
|
|
align = html_lib.escape(str(a.get("align", "left")))
|
|
color = html_lib.escape(str(a.get("color", "var(--forge-text)")))
|
|
style = (
|
|
f"font-size:{font_size}; font-weight:{weight}; line-height:{line_height}; "
|
|
f"font-style:{font_style}; text-decoration:{text_decoration}; text-align:{align}; color:{color};"
|
|
)
|
|
return f'<div class="docText" data-element-id="{el["id"]}" data-kind="{el["kind"]}" style="{style}">{content}</div>'
|
|
|
|
|
|
def _render_image(el: dict[str, Any], _children_by_parent: dict[int | None, list[dict[str, Any]]]) -> str:
|
|
a = el["attributes"]
|
|
src = html_lib.escape(str(a.get("src", "")))
|
|
alt = html_lib.escape(str(a.get("alt", "")))
|
|
if not src:
|
|
return (
|
|
f'<div class="docImage docImagePlaceholder" data-element-id="{el["id"]}" data-kind="image">'
|
|
f"Image — aucun fichier choisi</div>"
|
|
)
|
|
return f'<img class="docImage" data-element-id="{el["id"]}" data-kind="image" src="{src}" alt="{alt}">'
|
|
|
|
|
|
def _render_button(el: dict[str, Any], _children_by_parent: dict[int | None, list[dict[str, Any]]]) -> str:
|
|
a = el["attributes"]
|
|
label = html_lib.escape(str(a.get("label", "Bouton")))
|
|
target = html_lib.escape(str(a.get("target", "")))
|
|
target_attr = f' data-target="{target}"' if target else ""
|
|
return (
|
|
f'<button type="button" class="docButton" data-element-id="{el["id"]}" data-kind="bouton"{target_attr}>'
|
|
f"{label}</button>"
|
|
)
|
|
|
|
|
|
def _render_minigame_placeholder(
|
|
el: dict[str, Any], _children_by_parent: dict[int | None, list[dict[str, Any]]]
|
|
) -> str:
|
|
from ..labels.element_kind_labels import ELEMENT_KIND_LABELS
|
|
|
|
a = el["attributes"]
|
|
theme_color = html_lib.escape(str(a.get("theme_color", "#ff5f2e")))
|
|
label = html_lib.escape(ELEMENT_KIND_LABELS.get(el["kind"], el["kind"]))
|
|
return (
|
|
f'<div class="docMinigame" data-element-id="{el["id"]}" data-kind="{el["kind"]}" '
|
|
f'style="border-color:{theme_color};">'
|
|
f'<div class="docMinigameBadge">'
|
|
f'<span class="docMinigameLabel">{label}</span>'
|
|
f'<span class="docMinigamePlaceholder">Formulaire de contenu à venir</span>'
|
|
f"</div></div>"
|
|
)
|
|
|
|
|
|
def _render_quiz_player(config: dict[str, Any]) -> str:
|
|
"""Questionnaire RÉELLEMENT interactif — affiché uniquement en Mode
|
|
Aperçu (voir static/document/document-editor.css,
|
|
.docEditor3--preview), pour que le créateur puisse tester son quiz
|
|
avant un futur export (voir docs/plan/maquettes/
|
|
document-formation-web.html, référence visuelle de ce rendu). Les
|
|
questions sont embarquées en JSON dans un attribut data-* (jamais un
|
|
<script> par élément) — static/document/js/document-editor.js les lit
|
|
et gère tout le déroulé (réponse/score/question suivante/résultat)
|
|
côté client, sans aucun aller-retour serveur (une session d'Aperçu
|
|
n'est jamais persistée)."""
|
|
config_json = html_lib.escape(json.dumps({"questions": config["questions"]}), quote=True)
|
|
return (
|
|
f'<div class="docQuizPlayer" data-quiz-config="{config_json}">'
|
|
f'<div class="docQuizCard">'
|
|
f'<div class="docQuizKicker">Quiz</div>'
|
|
f'<div class="docQuizPlayerTitle">Vérifiez vos connaissances</div>'
|
|
f'<div class="docQuizProgressTrack"><div class="docQuizProgressFill"></div></div>'
|
|
f'<div class="docQuizMeta"><span class="docQuizProg"></span><span class="docQuizScore"></span></div>'
|
|
f'<div class="docQuizQuestionText"></div>'
|
|
f'<div class="docQuizOptions"></div>'
|
|
f'<div class="docQuizFeedback"></div>'
|
|
f'<div class="docQuizNextBar"><button type="button" class="docQuizNextBtn">Question suivante</button></div>'
|
|
f"</div>"
|
|
f'<div class="docQuizResultCard" style="display:none;">'
|
|
f'<div class="docQuizResultBig"></div>'
|
|
f'<div class="docQuizResultSub"></div>'
|
|
f'<button type="button" class="docQuizRestartBtn">Recommencer le quiz</button>'
|
|
f"</div></div>"
|
|
)
|
|
|
|
|
|
def _render_quiz(el: dict[str, Any], _children_by_parent: dict[int | None, list[dict[str, Any]]]) -> str:
|
|
from ..labels.quiz_config import quiz_total_points, sanitize_quiz_config
|
|
|
|
config = sanitize_quiz_config(el["attributes"])
|
|
theme_color = html_lib.escape(str(config["theme_color"]))
|
|
question_count = len(config["questions"])
|
|
question_label = "question" if question_count <= 1 else "questions"
|
|
total_points = quiz_total_points(config)
|
|
timer_note = f" · ⏱ {config['timer_seconds']}s/question" if config["timer_enabled"] else ""
|
|
subtitle = f"{question_count} {question_label} · {total_points} points{timer_note}"
|
|
player_html = _render_quiz_player(config) if config["questions"] else ""
|
|
return (
|
|
f'<div class="docMinigame" data-element-id="{el["id"]}" data-kind="quiz" '
|
|
f'style="border-color:{theme_color};">'
|
|
f'<div class="docMinigameBadge">'
|
|
f'<span class="docMinigameLabel">Quiz</span>'
|
|
f'<span class="docMinigamePlaceholder">{html_lib.escape(subtitle)}</span>'
|
|
f"</div>"
|
|
f"{player_html}"
|
|
f"</div>"
|
|
)
|
|
|
|
|
|
def _render_association_player(config: dict[str, Any]) -> str:
|
|
"""Plateau de glisser-déposer RÉELLEMENT interactif — affiché
|
|
uniquement en Mode Aperçu, même principe que _render_quiz_player :
|
|
aucun aller-retour serveur, tout le déroulé (glisser une carte de
|
|
gauche sur son emplacement de droite, ou cliquer les deux) est géré
|
|
par static/document/js/document-editor.js à partir du JSON embarqué.
|
|
Les deux colonnes sont mélangées INDÉPENDAMMENT (sinon la position
|
|
suffirait à deviner l'association, sans avoir à lire quoi que ce
|
|
soit) — random.shuffle : mélange d'affichage pour un mini-jeu,
|
|
jamais un usage cryptographique (voir CODE_QUALITY.md)."""
|
|
pairs = config["pairs"]
|
|
left_items = [{"pair_index": i, "text": p["left"]} for i, p in enumerate(pairs)]
|
|
right_items = [{"pair_index": i, "text": p["right"]} for i, p in enumerate(pairs)]
|
|
random.shuffle(left_items) # NOSONAR python:S2245 - melange d'affichage, pas un usage cryptographique
|
|
random.shuffle(right_items) # NOSONAR python:S2245 - idem
|
|
config_json = html_lib.escape(json.dumps({"left": left_items, "right": right_items}), quote=True)
|
|
return (
|
|
f'<div class="docAssocPlayer" data-assoc-config="{config_json}">'
|
|
f'<div class="docAssocCard">'
|
|
f'<div class="docQuizKicker">Association</div>'
|
|
f'<div class="docAssocTitle">Associez chaque élément à sa correspondance</div>'
|
|
f'<div class="docAssocMeta"><span class="docAssocProg"></span></div>'
|
|
f'<div class="docAssocBoard">'
|
|
f'<div class="docAssocColumn docAssocColumnLeft"></div>'
|
|
f'<div class="docAssocColumn docAssocColumnRight"></div>'
|
|
f"</div>"
|
|
f'<div class="docAssocFeedback"></div>'
|
|
# Le plateau reste affiché une fois toutes les paires trouvées
|
|
# (voir docs/plan/PLAN.md, retour utilisateur du 20/09/2026) —
|
|
# seul ce bouton apparaît (.is-visible posé par
|
|
# forgeDocAssociationMatchResult), jamais un écran de résultat
|
|
# séparé qui remplacerait le plateau (ça reste le comportement du
|
|
# Quiz, seul mini-jeu concerné par un écran de fin distinct).
|
|
f'<div class="docMinigameRestartBar">'
|
|
f'<button type="button" class="docAssocRestartBtn">Recommencer</button>'
|
|
f"</div>"
|
|
f"</div></div>"
|
|
)
|
|
|
|
|
|
def _render_association(el: dict[str, Any], _children_by_parent: dict[int | None, list[dict[str, Any]]]) -> str:
|
|
from ..labels.association_config import sanitize_association_config
|
|
|
|
config = sanitize_association_config(el["attributes"])
|
|
theme_color = html_lib.escape(str(config["theme_color"]))
|
|
pair_count = len(config["pairs"])
|
|
pair_label = "paire" if pair_count <= 1 else "paires"
|
|
player_html = _render_association_player(config) if config["pairs"] else ""
|
|
return (
|
|
f'<div class="docMinigame" data-element-id="{el["id"]}" data-kind="association" '
|
|
f'style="border-color:{theme_color};">'
|
|
f'<div class="docMinigameBadge">'
|
|
f'<span class="docMinigameLabel">Association</span>'
|
|
f'<span class="docMinigamePlaceholder">{pair_count} {pair_label}</span>'
|
|
f"</div>"
|
|
f"{player_html}"
|
|
f"</div>"
|
|
)
|
|
|
|
|
|
def _render_memory_player(config: dict[str, Any]) -> str:
|
|
"""Plateau de Memory RÉELLEMENT interactif — affiché uniquement en
|
|
Mode Aperçu, même principe que _render_quiz_player/
|
|
_render_association_player. En mode "paire", chaque carte définie par
|
|
le créateur est dupliquée en deux instances partageant le même
|
|
card_index (l'appariement se fait dessus) ; en mode "single", une
|
|
seule instance par carte (simple retournement, sans appariement).
|
|
Les instances sont mélangées une seule fois ici (jamais recalculées
|
|
à chaque rendu répété d'un même Aperçu, voir la remarque dans
|
|
static/document/js/document-editor.js sur la ré-init au
|
|
rafraîchissement du canevas) puis embarquées en JSON."""
|
|
cards = config["cards"]
|
|
mode = config["mode"]
|
|
instances = []
|
|
for i, card in enumerate(cards):
|
|
instances.append({"card_index": i, "recto": card["recto"], "verso": card["verso"]})
|
|
if mode == "paire":
|
|
instances.append({"card_index": i, "recto": card["recto"], "verso": card["verso"]})
|
|
random.shuffle(instances) # NOSONAR python:S2245 - melange d'affichage, pas un usage cryptographique
|
|
config_json = html_lib.escape(json.dumps({"mode": mode, "cards": instances}), quote=True)
|
|
return (
|
|
f'<div class="docMemoryPlayer" data-memory-config="{config_json}">'
|
|
f'<div class="docMemoryCardWrap">'
|
|
f'<div class="docQuizKicker">Memory</div>'
|
|
f'<div class="docAssocTitle docMemoryTitle"></div>'
|
|
f'<div class="docAssocMeta"><span class="docMemoryProg"></span></div>'
|
|
f'<div class="docMemoryGrid"></div>'
|
|
# Même choix que l'Association ci-dessus : le plateau reste
|
|
# affiché une fois le jeu terminé, seul ce bouton apparaît.
|
|
f'<div class="docMinigameRestartBar">'
|
|
f'<button type="button" class="docMemoryRestartBtn">Recommencer</button>'
|
|
f"</div>"
|
|
f"</div></div>"
|
|
)
|
|
|
|
|
|
def _render_memory(el: dict[str, Any], _children_by_parent: dict[int | None, list[dict[str, Any]]]) -> str:
|
|
from ..labels.memory_config import sanitize_memory_config
|
|
|
|
config = sanitize_memory_config(el["attributes"])
|
|
theme_color = html_lib.escape(str(config["theme_color"]))
|
|
card_count = len(config["cards"])
|
|
card_label = "carte" if card_count <= 1 else "cartes"
|
|
mode_label = "mode paire" if config["mode"] == "paire" else "mode simple"
|
|
subtitle = f"{card_count} {card_label} · {mode_label}"
|
|
player_html = _render_memory_player(config) if config["cards"] else ""
|
|
return (
|
|
f'<div class="docMinigame" data-element-id="{el["id"]}" data-kind="memory" '
|
|
f'style="border-color:{theme_color};">'
|
|
f'<div class="docMinigameBadge">'
|
|
f'<span class="docMinigameLabel">Memory</span>'
|
|
f'<span class="docMinigamePlaceholder">{html_lib.escape(subtitle)}</span>'
|
|
f"</div>"
|
|
f"{player_html}"
|
|
f"</div>"
|
|
)
|
|
|
|
|
|
_MOTS_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
|
_MOTS_DIRECTIONS = (
|
|
(0, 1), # horizontale, gauche -> droite
|
|
(1, 0), # verticale, haut -> bas
|
|
(1, 1), # diagonale droite, haut-gauche -> bas-droite
|
|
(1, -1), # diagonale gauche, haut-droite -> bas-gauche
|
|
)
|
|
_MOTS_MAX_PLACEMENT_ATTEMPTS = 200
|
|
_MOTS_MAX_GRID_GROWTH_ATTEMPTS = 20
|
|
|
|
|
|
def _mots_grid_size_for_words(words: list[str]) -> int:
|
|
"""Taille de départ de la grille (carrée) — assez grande pour loger le
|
|
plus long mot ET laisser assez de cases libres pour le remplissage
|
|
aléatoire, sans grille disproportionnée pour une courte liste de mots.
|
|
_build_mots_grid grandit cette taille si le placement échoue malgré
|
|
tout (mots qui se contraignent mutuellement), donc une estimation
|
|
approximative suffit ici."""
|
|
from ..labels.mots_config import MIN_WORD_LENGTH
|
|
|
|
longest = max((len(w) for w in words), default=MIN_WORD_LENGTH)
|
|
total_letters = sum(len(w) for w in words)
|
|
return max(longest, 8, int(total_letters**0.5) + 2)
|
|
|
|
|
|
def _mots_can_place_word(
|
|
grid: list[list[str]], word: str, row: int, col: int, delta_row: int, delta_col: int, size: int
|
|
) -> bool:
|
|
for i, letter in enumerate(word):
|
|
r, c = row + i * delta_row, col + i * delta_col
|
|
if not (0 <= r < size and 0 <= c < size):
|
|
return False
|
|
if grid[r][c] not in ("", letter):
|
|
return False
|
|
return True
|
|
|
|
|
|
def _mots_place_word(grid: list[list[str]], word: str, size: int) -> list[list[int]] | None:
|
|
"""None si aucun emplacement libre n'a été trouvé après le nombre
|
|
d'essais autorisé — laisse l'appelant décider (agrandir la grille et
|
|
tout retenter, voir _build_mots_grid) plutôt que de placer le mot de
|
|
force en écrasant des lettres déjà posées."""
|
|
for _ in range(_MOTS_MAX_PLACEMENT_ATTEMPTS):
|
|
delta_row, delta_col = random.choice(_MOTS_DIRECTIONS) # nosec B311 # noqa: S311 - placement de mot, jamais crypto # NOSONAR python:S2245
|
|
row = random.randint(0, size - 1) # nosec B311 # noqa: S311 - idem # NOSONAR python:S2245
|
|
col = random.randint(0, size - 1) # nosec B311 # noqa: S311 - idem # NOSONAR python:S2245
|
|
if not _mots_can_place_word(grid, word, row, col, delta_row, delta_col, size):
|
|
continue
|
|
cells = []
|
|
for i, letter in enumerate(word):
|
|
r, c = row + i * delta_row, col + i * delta_col
|
|
grid[r][c] = letter
|
|
cells.append([r, c])
|
|
return cells
|
|
return None
|
|
|
|
|
|
def _mots_attempt_placement(words: list[str], size: int) -> dict[str, Any] | None:
|
|
grid: list[list[str]] = [["" for _ in range(size)] for _ in range(size)]
|
|
placed_words = []
|
|
# Les mots les plus longs sont placés en premier : ce sont les plus
|
|
# difficiles à caser, autant le faire tant que la grille est encore
|
|
# majoritairement libre.
|
|
for word in sorted(words, key=len, reverse=True):
|
|
cells = _mots_place_word(grid, word, size)
|
|
if cells is None:
|
|
return None
|
|
placed_words.append({"text": word, "cells": cells})
|
|
for r in range(size):
|
|
for c in range(size):
|
|
if not grid[r][c]:
|
|
grid[r][c] = random.choice(_MOTS_ALPHABET) # nosec B311 # noqa: S311 - lettre de remplissage, jamais crypto # NOSONAR python:S2245
|
|
return {"size": size, "grid": grid, "words": placed_words}
|
|
|
|
|
|
def _build_mots_grid(words: list[str]) -> dict[str, Any]:
|
|
"""Construit la grille ET la position exacte de chaque mot (jamais
|
|
recalculée côté client, voir _render_mots_player) : une grille carrée,
|
|
chaque mot placé horizontalement/verticalement/en diagonale (deux sens
|
|
de diagonale seulement, jamais à l'envers — voir _MOTS_DIRECTIONS),
|
|
les cases restantes remplies de lettres aléatoires. Si un mot ne
|
|
trouve pas sa place (mots qui se contraignent mutuellement), la
|
|
grille entière est agrandie et le placement retenté depuis zéro,
|
|
plutôt que d'abandonner silencieusement ce mot."""
|
|
if not words:
|
|
return {"size": 0, "grid": [], "words": []}
|
|
size = _mots_grid_size_for_words(words)
|
|
for _ in range(_MOTS_MAX_GRID_GROWTH_ATTEMPTS):
|
|
result = _mots_attempt_placement(words, size)
|
|
if result is not None:
|
|
return result
|
|
size += 2
|
|
# Filet de sécurité théorique : avec MAX_WORDS=10 mots de
|
|
# MAX_WORD_LENGTH=20 lettres au plus, la grille finit toujours par
|
|
# être assez grande pour tous les loger bien avant cette limite.
|
|
return _mots_attempt_placement(words, size) or {"size": size, "grid": [], "words": []}
|
|
|
|
|
|
def _render_mots_player(config: dict[str, Any]) -> str:
|
|
"""Grille de mots mêlés RÉELLEMENT interactive — affichée uniquement
|
|
en Mode Aperçu, même principe que les autres mini-jeux : la grille ET
|
|
la position exacte de chaque mot sont calculées ICI côté serveur
|
|
(_build_mots_grid, jamais recalculées côté client) puis embarquées en
|
|
JSON ; static/document/js/document-editor.js compare les coordonnées
|
|
de la sélection de l'apprenant aux coordonnées exactes de chaque mot
|
|
— jamais une simple comparaison de texte, qui se tromperait sur des
|
|
lettres partagées entre deux mots qui se croisent."""
|
|
built = _build_mots_grid(config["words"])
|
|
config_json = html_lib.escape(json.dumps(built), quote=True)
|
|
return (
|
|
f'<div class="docMotsPlayer" data-mots-config="{config_json}">'
|
|
f'<div class="docAssocCard">'
|
|
f'<div class="docQuizKicker">Mots mêlés</div>'
|
|
f'<div class="docAssocTitle">Retrouvez chaque mot caché dans la grille</div>'
|
|
f'<div class="docAssocMeta"><span class="docMotsProg"></span></div>'
|
|
f'<div class="docMotsBoard">'
|
|
f'<div class="docMotsGrid"></div>'
|
|
f'<div class="docMotsWordList"></div>'
|
|
f"</div>"
|
|
f'<div class="docMinigameRestartBar">'
|
|
f'<button type="button" class="docMotsRestartBtn">Recommencer</button>'
|
|
f"</div>"
|
|
f"</div></div>"
|
|
)
|
|
|
|
|
|
def _render_mots(el: dict[str, Any], _children_by_parent: dict[int | None, list[dict[str, Any]]]) -> str:
|
|
from ..labels.mots_config import sanitize_mots_config
|
|
|
|
config = sanitize_mots_config(el["attributes"])
|
|
theme_color = html_lib.escape(str(config["theme_color"]))
|
|
word_count = len(config["words"])
|
|
word_label = "mot" if word_count <= 1 else "mots"
|
|
player_html = _render_mots_player(config) if config["words"] else ""
|
|
return (
|
|
f'<div class="docMinigame" data-element-id="{el["id"]}" data-kind="mots" '
|
|
f'style="border-color:{theme_color};">'
|
|
f'<div class="docMinigameBadge">'
|
|
f'<span class="docMinigameLabel">Mots mêlés</span>'
|
|
f'<span class="docMinigamePlaceholder">{word_count} {word_label}</span>'
|
|
f"</div>"
|
|
f"{player_html}"
|
|
f"</div>"
|
|
)
|
|
|
|
|
|
def _render_scenario_player(config: dict[str, Any]) -> str:
|
|
"""Mise en situation RÉELLEMENT interactive — affichée uniquement en
|
|
Mode Aperçu, même principe que les autres mini-jeux : aucun
|
|
aller-retour serveur, tout le déroulé (navigation dans l'arbre de
|
|
décision, scénario suivant) est géré par static/document/js/
|
|
document-editor.js à partir du JSON embarqué (voir scenario_config.py
|
|
pour la forme exacte d'un scénario : {"title", "nodes"}, nodes[0]
|
|
étant la situation initiale). Les scénarios (plusieurs arbres
|
|
indépendants) gardent l'ORDRE d'écriture du créateur (contrairement à
|
|
l'Association/Memory/Mots mêlés, jamais mélangés) : ce sont des mises
|
|
en situation séquentielles, pas des éléments à faire correspondre ou
|
|
retrouver — un mélange n'aurait ici aucun sens pédagogique. Un seul
|
|
bloc de texte (.docScenarioSituation) sert successivement à afficher
|
|
le texte de chaque nœud visité : une fois un choix fait, le texte du
|
|
nœud suivant REMPLACE le précédent (les boutons de choix disparaissent
|
|
avec lui) — volontairement PAS le comportement du Quiz, où la
|
|
question resterait affichée à côté d'un encart de feedback séparé.
|
|
Aucune notion de bonne/mauvaise réponse ici (retour utilisateur du
|
|
20/09/2026 : "il n'y a pas de notion vrai/faux, l'utilisateur observe
|
|
les conséquences") — un nœud sans choix est simplement une fin de
|
|
branche. Réutilise les classes visuelles du Quiz
|
|
(.docQuizOptions/.docQuizNextBar/.docQuizQuestionText) plutôt que de
|
|
dupliquer ces règles, même esprit que .docAssocCard/
|
|
.docMinigameRestartBar."""
|
|
config_json = html_lib.escape(json.dumps({"scenarios": config["scenarios"]}), quote=True)
|
|
return (
|
|
f'<div class="docScenarioPlayer" data-scenario-config="{config_json}">'
|
|
f'<div class="docAssocCard">'
|
|
f'<div class="docQuizKicker">Scénario</div>'
|
|
f'<div class="docAssocMeta"><span class="docScenarioProg"></span></div>'
|
|
f'<div class="docScenarioSituation docQuizQuestionText"></div>'
|
|
f'<div class="docScenarioChoices docQuizOptions"></div>'
|
|
f'<div class="docQuizNextBar">'
|
|
f'<button type="button" class="docQuizNextBtn docScenarioNextBtn">Scénario suivant</button>'
|
|
f"</div>"
|
|
# Même choix que l'Association/Memory/Mots mêlés : le dernier
|
|
# scénario reste affiché une fois répondu, seul ce bouton
|
|
# apparaît (jamais un écran de résultat séparé — ça reste le
|
|
# comportement du Quiz).
|
|
f'<div class="docMinigameRestartBar">'
|
|
f'<button type="button" class="docScenarioRestartBtn">Recommencer</button>'
|
|
f"</div>"
|
|
f"</div></div>"
|
|
)
|
|
|
|
|
|
def _render_scenario(el: dict[str, Any], _children_by_parent: dict[int | None, list[dict[str, Any]]]) -> str:
|
|
from ..labels.scenario_config import sanitize_scenario_config
|
|
|
|
config = sanitize_scenario_config(el["attributes"])
|
|
theme_color = html_lib.escape(str(config["theme_color"]))
|
|
scenario_count = len(config["scenarios"])
|
|
scenario_label = "scénario" if scenario_count <= 1 else "scénarios"
|
|
player_html = _render_scenario_player(config) if config["scenarios"] else ""
|
|
return (
|
|
f'<div class="docMinigame" data-element-id="{el["id"]}" data-kind="scenario" '
|
|
f'style="border-color:{theme_color};">'
|
|
f'<div class="docMinigameBadge">'
|
|
f'<span class="docMinigameLabel">Scénario</span>'
|
|
f'<span class="docMinigamePlaceholder">{scenario_count} {scenario_label}</span>'
|
|
f"</div>"
|
|
f"{player_html}"
|
|
f"</div>"
|
|
)
|
|
|
|
|
|
def _render_unknown(el: dict[str, Any], _children_by_parent: dict[int | None, list[dict[str, Any]]]) -> str:
|
|
return f'<div class="docUnknown" data-element-id="{el["id"]}">Type inconnu : {html_lib.escape(el["kind"])}</div>'
|
|
|
|
|
|
_RENDERERS = {
|
|
"row": _render_row,
|
|
"rectangle": _render_shape,
|
|
"cercle": _render_shape,
|
|
"triangle": _render_shape,
|
|
"trait": _render_shape,
|
|
"titre": _render_text,
|
|
"paragraphe": _render_text,
|
|
"image": _render_image,
|
|
"bouton": _render_button,
|
|
"quiz": _render_quiz,
|
|
"association": _render_association,
|
|
"memory": _render_memory,
|
|
"mots": _render_mots,
|
|
"scenario": _render_scenario,
|
|
"zones": _render_minigame_placeholder,
|
|
}
|