Files
Forge-Engine/document_engine/rendering/render_document_element.py
T
williamandClaude Sonnet 5 9d87a4bfef
Build and deploy / test-python (push) Failing after 1m3s
Build and deploy / test-js (push) Successful in 48s
Build and deploy / lint-python (push) Failing after 1m2s
Build and deploy / lint-js (push) Failing after 1m5s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Build and deploy / sonarqube (push) Failing after 1m5s
Implemente le mini-jeu Quiz (questions/choix/points/timer)
document_engine/labels/quiz_config.py (nouveau) : modele de donnees
complet, meme convention resolve_X/sanitize_X que
game_engine/rendering/quiz_box_config.py cote jeu (aucun import
croise) - DEFAULT_QUIZ_CONFIG, sanitize_quiz_config (valide/nettoie
chaque question independamment, jamais ne leve, tronque a 2-4 choix,
remet correct_index a 0 si hors bornes/non-entier, clampe points >= 0
et timer_seconds dans [5,300]), quiz_total_points.

Cote serveur, routes/document/document_element_update.py revalide
desormais un quiz avant persistance (seul kind qui en a besoin - les
autres n'ont que des attributs scalaires sans structure a garantir) et
renvoie les attributs REELLEMENT persistes dans sa reponse, pour que
le client ne derive jamais de la verite serveur apres un nettoyage
serveur (ex. choix en trop tronque).

Cote editeur (static/document/js/document-editor.js), le panneau
Proprietes d'un quiz est desormais reel : chronometre optionnel,
couleur de theme, liste de questions repetable (ajout/suppression),
chacune avec son texte, un nombre de choix ajustable (2-4, les
inputs texte suivent), le choix correct via un radio par question,
et les points gagnes. Le rendu canevas (render_document_element.py)
affiche un resume reel (nombre de questions, total des points,
minuteur) au lieu du placeholder generique.

Tests : document_engine/labels/quiz_config.py couvert par 13 tests
purs (tests/document/test_quiz_config.py, sans Flask - defauts,
troncature, validation, cas limites dont bool comme correct_index) +
1 test de route verifiant la sanitization a l'ecriture et le rendu.

SKIP=djlint : backlog H021 pre-existant, aucun template touche ici.
ruff/mypy --strict/vulture/bandit/import-linter/eslint/stylelint tous
verts ; 63 tests cibles (document + onboarding + auth) verifies
fraichement + verification manuelle live via le serveur de dev
(ajout, sanitization sur choix invalides/en trop, rendu du resume
avec minuteur).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 11:40:48 +02:00

179 lines
8.2 KiB
Python

import html as html_lib
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'<span class="docMinigameLabel">{label}</span>'
f'<span class="docMinigamePlaceholder">Formulaire de contenu à venir</span>'
f"</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}"
return (
f'<div class="docMinigame" data-element-id="{el["id"]}" data-kind="quiz" '
f'style="border-color:{theme_color};">'
f'<span class="docMinigameLabel">Quiz</span>'
f'<span class="docMinigamePlaceholder">{html_lib.escape(subtitle)}</span>'
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,
}