Build and deploy / test-python (push) Failing after 1m5s
Build and deploy / test-js (push) Successful in 51s
Build and deploy / lint-python (push) Failing after 1m2s
Build and deploy / lint-js (push) Failing after 1m4s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Build and deploy / sonarqube (push) Failing after 1m0s
Le quiz affichait jusqu'ici la meme carte resume en edition comme en Apercu. Ajoute un vrai questionnaire jouable, visible UNIQUEMENT en Mode Apercu (masque en edition via CSS, jamais les deux a la fois) : options de reponse cliquables, marquage correct/incorrect immediat, score qui accumule les points de chaque question repondue correctement, progression "Question suivante", et un ecran de resultat final (score obtenu / total des points possibles) avec "Recommencer le quiz" - structure et identite visuelle calquees sur docs/plan/maquettes/document-formation-web.html (cartes en degrade, options A/B/C/D, feedback vert/orange), adaptees aux tokens --doc-* deja en place (theme clair/sombre inclus, nouvelles variables --doc-quiz-success-*/--doc-quiz-danger-*). Cote rendu (document_engine/rendering/render_document_element.py), _render_quiz ajoute desormais le questionnaire (fonction privee _render_quiz_player) des qu'au moins une question existe : les questions sont embarquees en JSON dans un attribut data-quiz-config (jamais un <script> par element), echappees pour l'HTML - aucun aller-retour serveur pendant qu'on joue, tout le deroulé (reponse/ score/suivant/resultat) est gere par static/document/js/document-editor.js. pointer-events, desactive sur tout element en Apercu pour empecher la selection/le glisser-deposer pendant le test, est reactive specifiquement dans le questionnaire pour qu'il reste reellement cliquable. Tests : 3 nouveaux tests de rendu purs (sans Flask, dont un qui verifie l'echappement HTML du texte d'une question contre une injection). Verifie en live via une simulation DOM complete (basculer Apercu, repondre correctement puis incorrectement, verifier score/feedback/etat des options, passer a la question suivante, voir l'ecran de resultat) - le script de diagnostic n'a pas ete conserve dans le depot. SKIP=djlint : backlog H021 pre-existant, aucun template touche ici. ruff/mypy --strict/vulture/bandit/import-linter/eslint/stylelint tous verts ; 48 tests cibles (document + onboarding) verifies frais. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
217 lines
10 KiB
Python
217 lines
10 KiB
Python
import html as html_lib
|
|
import json
|
|
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_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_minigame_placeholder,
|
|
"memory": _render_minigame_placeholder,
|
|
"mots": _render_minigame_placeholder,
|
|
"scenario": _render_minigame_placeholder,
|
|
"zones": _render_minigame_placeholder,
|
|
}
|