document_engine/labels/memory_config.py (nouveau) : modele de donnees, meme convention resolve_X/sanitize_X que quiz_config.py/ association_config.py - DEFAULT_MEMORY_CONFIG, sanitize_memory_config. Chaque carte a un recto ET un verso, chacun avec image/texte independants et tous deux optionnels ; seul le verso doit avoir au moins l'un des deux non vide (rien a reveler/apparier sinon) - le recto peut rester entierement vide (dos de carte generique "?" par defaut). Liste tronquee a MAX_CARDS=8. Cote serveur, routes/document/document_element_update.py revalide desormais aussi memory avant persistance. Le rendu (_render_memory) affiche un resume reel (nombre de cartes, mode) et, des qu'au moins une carte existe, un plateau de retournement REELEMENT interactif en Mode Apercu (_render_memory_player) : en mode "paire", chaque carte definie est DUPLIQUEE en deux instances partageant le meme card_index (appariement classique) ; en mode "single", une seule instance par carte (simple retournement, sans appariement - "c'est donc un retourner de carte classique plus un jeu memory"). Les instances sont melangees (random.shuffle, documente dans CODE_QUALITY.md) puis embarquees en JSON dans data-memory-config. Cote editeur, le panneau Proprietes propose un bascule segmentee Paire/Simple et une liste de cartes repetable, chaque carte avec ses deux faces (recto/verso) editables independamment (image + texte). Extrait au passage forgeDocEscapeHtml (ex-forgeDocEscapeForTextarea, generalise pour couvrir aussi les attributs) reutilise pour les deux mini-jeux. Le plateau jouable (static/document/js/document-editor.js) est une vraie carte-retournement CSS 3D (perspective/rotateY), contenu de chaque face construit via DOM (textContent/img.src, jamais innerHTML avec le texte du createur - meme precaution que le plateau Association) : bon appariement verrouille en vert, mauvais reinitialise apres un delai, ecran de resultat une fois le jeu termine (les deux modes), et un "Recommencer" qui remelange reellement les cartes (Fisher-Yates cote client). Tests : 11 tests purs (tests/document/test_memory_config.py, sans Flask, dont un qui verifie explicitement la duplication en mode paire vs son absence en mode single) + 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 ; 62 tests document verifies frais. Verification manuelle live complete : ajout, sanitization sur carte invalide, rendu du plateau, et simulation DOM du gameplay reel dans les DEUX modes (mode paire : mauvais appariement puis bon appariement puis jeu complet ; mode single : retournement puis jeu complet) - script de diagnostic non conserve dans le depot. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
332 lines
16 KiB
Python
332 lines
16 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_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>'
|
|
f"</div>"
|
|
f'<div class="docAssocResultCard docMemoryResultCard" style="display:none;">'
|
|
f'<div class="docAssocResultBig">✓</div>'
|
|
f'<div class="docAssocResultSub docMemoryResultSub"></div>'
|
|
f'<button type="button" class="docAssocRestartBtn docMemoryRestartBtn">Recommencer</button>'
|
|
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>"
|
|
)
|
|
|
|
|
|
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_minigame_placeholder,
|
|
"scenario": _render_minigame_placeholder,
|
|
"zones": _render_minigame_placeholder,
|
|
}
|