document_engine/labels/association_config.py (nouveau) : modele de
donnees, meme convention resolve_X/sanitize_X que quiz_config.py -
DEFAULT_ASSOCIATION_CONFIG, sanitize_association_config (chaque paire
doit avoir ses deux cotes non vides, sinon supprimee silencieusement ;
liste tronquee a MAX_PAIRS=8).
Cote serveur, routes/document/document_element_update.py revalide
desormais aussi l'association avant persistance (meme raisonnement que
pour le quiz). Le rendu (_render_association) affiche un resume reel
(nombre de paires) et, des qu'au moins une paire existe, un plateau de
glisser-deposer REELEMENT interactif en Mode Apercu
(_render_association_player) : les deux colonnes (termes/
correspondances) sont melangees independamment (random.shuffle,
melange d'affichage documente dans CODE_QUALITY.md) puis embarquees en
JSON dans un attribut data-assoc-config.
Cote editeur, le panneau Proprietes d'une association ("relier
visuellement deux champs qui vont ensemble") est une liste de paires
repetable, chaque ligne reliant visuellement un champ "Element" et un
champ "Correspondance" par un glyphe ↔. Le plateau jouable en Apercu
(static/document/js/document-editor.js) supporte deux facons de jouer,
toutes deux reelles : glisser-deposer HTML5 natif, ou cliquer une
carte puis son emplacement (repli pour les appareils sans support
fiable du drag) - bonne association verrouillee en vert, mauvaise
signalee puis reinitialisee, ecran de resultat une fois toutes les
paires associees.
Bug reel trouve ET corrige via simulation DOM complete (glisser-
deposer + clic simules, pas juste un chargement de page) : le
feedback visuel reutilisait la classe CSS du quiz via une
reaffectation de className qui supprimait au passage la classe
d'identite docAssocFeedback, rendant l'element introuvable des le
premier essai de match (aurait plante en usage reel des la premiere
tentative). Corrige en gardant toujours les deux classes ensemble.
Tests : 8 tests purs (tests/document/test_association_config.py, sans
Flask) + 1 test de route verifiant la sanitization a l'ecriture.
SKIP=djlint : backlog H021 pre-existant, aucun template touche ici.
ruff/mypy --strict/vulture/bandit/import-linter/eslint/stylelint tous
verts ; 50 tests document verifies frais. Verification manuelle live
complete : ajout, sanitization sur paire invalide, rendu du plateau,
et simulation DOM du gameplay reel (glisser-deposer correct/incorrect,
clic-selection, progression, ecran de resultat) - script de
diagnostic non conserve dans le depot.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
274 lines
13 KiB
Python
274 lines
13 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>'
|
|
f"</div>"
|
|
f'<div class="docAssocResultCard" style="display:none;">'
|
|
f'<div class="docAssocResultBig">✓</div>'
|
|
f'<div class="docAssocResultSub">Toutes les paires sont associées !</div>'
|
|
f'<button type="button" class="docAssocRestartBtn">Recommencer</button>'
|
|
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_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_minigame_placeholder,
|
|
"mots": _render_minigame_placeholder,
|
|
"scenario": _render_minigame_placeholder,
|
|
"zones": _render_minigame_placeholder,
|
|
}
|