Files
Forge-Engine/document_engine/rendering/render_document_element.py
T
williamandClaude Sonnet 5 4db1269348 Audit complet de mise en forme — Liste à puces/numérotée (4e élément)
Implémente toutes les options manquantes identifiées pour les listes :
typographie complète, style/position/couleur/taille de puce (validés
selon le kind), puce personnalisée en SVG pour les listes à puces,
padding uniforme par élément (nouveau, il n'y en avait aucun), espacement
entre éléments réglable, et tous les attributs de boîte partagés sur la
liste entière. Bordure/fond/padding par élément individuel et sous-listes
imbriquées volontairement différés (portée actée avec l'utilisateur
avant implémentation : transformeraient le stockage des éléments en
objets structurés, chantier bien plus lourd).

Trois ajouts transversaux bénéficiant à plusieurs éléments : sections
"Contenu"/"Conteneur" dans tous les panneaux de propriétés, alignement
vertical du contenu dans son bloc (Titre/Paragraphe/Liste/Image
légendée), et une option pour retirer un thème appliqué ("Aucun modèle"
dans la modale, avec une nouvelle fonction db.remove_document_theme).

Quatre bugs réels trouvés et corrigés en chaîne pendant la validation
avec le thème "Sécurité incendie" : un badge de thème s'affichait
au-dessus du texte au lieu d'à côté ; le correctif a d'abord fait
disparaître les puces/numéros natifs de TOUTES les listes (bug plus
grave que celui corrigé) ; puis un marqueur natif redondant apparaissait
à côté du badge du thème ; puis une règle CSS site-large de spécificité
supérieure empêchait silencieusement ce dernier correctif. Chaque étape
vérifiée par navigateur automatisé sur un support jetable.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-26 15:13:14 +02:00

885 lines
42 KiB
Python

import html as html_lib
import json
import random
import urllib.parse
from typing import Any
from .box_style import render_box_style, render_content_align
from .sanitize_svg_markup import sanitize_svg_markup
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>'
_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")
preset_font_size, base_weight, preset_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"
# underline/strikethrough se combinent (text-decoration-line accepte
# plusieurs valeurs) — retour utilisateur du 26/09/2026 : "barré"
# manquait à côté du souligné déjà existant.
decoration_parts = []
if a.get("underline"):
decoration_parts.append("underline")
if a.get("strikethrough"):
decoration_parts.append("line-through")
text_decoration = " ".join(decoration_parts) if decoration_parts else "none"
align = html_lib.escape(str(a.get("align", "left")))
color = html_lib.escape(str(a.get("color", "var(--forge-text)")))
# font_size/line_height : vides par défaut = valeurs du préréglage
# `style` (titre1/titre2/paragraphe/légende) inchangées ; une valeur
# explicite les remplace SANS changer `weight` (qui reste piloté par
# le préréglage + `bold`).
font_size = html_lib.escape(str(a.get("font_size", "")).strip()) or preset_font_size
line_height = html_lib.escape(str(a.get("line_height", "")).strip()) or str(preset_line_height)
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};"
)
text_transform = str(a.get("text_transform", "none"))
if text_transform and text_transform != "none":
style += f" text-transform:{html_lib.escape(text_transform)};"
font_family = str(a.get("font_family", "")).strip()
if font_family:
style += f" font-family:{html_lib.escape(font_family)};"
letter_spacing = str(a.get("letter_spacing", "")).strip()
if letter_spacing:
style += f" letter-spacing:{html_lib.escape(letter_spacing)};"
text_shadow = str(a.get("text_shadow", "")).strip()
if text_shadow:
style += f" text-shadow:{html_lib.escape(text_shadow)};"
# max_width (ex. "60ch", "480px" — retour utilisateur du 24/09/2026 :
# un paragraphe doit pouvoir rester plus étroit que la page, sans
# dépendre d'une rangée qui en partagerait la largeur avec un frère)
# fait maintenant partie des attributs de boîte partagés
# (render_box_style), jamais géré ici en double.
box_style = render_box_style(a)
if box_style:
style += f" {box_style}"
content_align = render_content_align(a)
if content_align:
style += f" {content_align}"
return f'<div class="docText" data-element-id="{el["id"]}" data-kind="{el["kind"]}" style="{style}">{content}</div>'
_IMAGE_OBJECT_FITS = ("cover", "contain", "fill")
_IMAGE_FILTERS = {
"grayscale": "grayscale(1)",
"sepia": "sepia(0.8)",
"blur": "blur(3px)",
}
def _image_extra_style(a: dict[str, Any]) -> str:
"""Déclarations CSS spécifiques à l'image (`object-fit`/`aspect-ratio`/
`filter`) — jamais dans `box_style.py` (partagé), qui ne connaît que
des attributs communs à plusieurs kinds."""
parts = []
object_fit = str(a.get("object_fit", ""))
if object_fit in _IMAGE_OBJECT_FITS:
parts.append(f"object-fit:{object_fit};")
aspect_ratio = str(a.get("aspect_ratio", "")).strip()
if aspect_ratio:
parts.append(f"aspect-ratio:{html_lib.escape(aspect_ratio)};")
filter_value = _IMAGE_FILTERS.get(str(a.get("filter_preset", "")))
if filter_value:
parts.append(f"filter:{filter_value};")
return " ".join(parts)
def _render_image(el: dict[str, Any], _children_by_parent: dict[int | None, list[dict[str, Any]]]) -> str:
a = el["attributes"]
click_behavior = str(a.get("click_behavior", ""))
link_url = str(a.get("link_url", "")).strip()
caption = str(a.get("caption", "")).strip()
# render_box_style (padding/margin/fond/bordure/largeur/position du
# bloc, dont align-self) doit se poser sur l'élément RÉELLEMENT
# top-niveau — celui qui est l'enfant direct du flex-column de la
# page (voir .docPageContent, static/document/document-editor.css) —
# jamais sur l'<img>/<div> interne dès qu'une légende ou un
# comportement au clic l'enveloppe : un align-self posé sur un
# DESCENDANT du flex-item n'a strictement aucun effet côté CSS (bug
# réel constaté le 26/09/2026 : "la position de bloc ne fonctionne
# pas sur l'image"). has_wrapper détermine qui, de l'image elle-même
# ou de son enveloppe, est ce top-niveau.
has_wrapper = bool(caption) or (click_behavior == "link" and link_url) or click_behavior == "lightbox"
box_style = render_box_style(a)
media_style = " ".join(p for p in (_image_extra_style(a), "" if has_wrapper else box_style) if p)
media_style_attr = f' style="{media_style}"' if media_style else ""
loading_attr = ' loading="lazy"' if a.get("lazy_load") else ""
svg_markup = str(a.get("svg_markup", "")).strip()
if svg_markup:
# Contenu vectoriel dessiné/collé par le créateur plutôt qu'un
# fichier hébergé — prioritaire sur `src` (voir
# element_kind_labels.element_default_attributes). Nettoyé à
# CHAQUE rendu (jamais seulement à l'écriture) par sanitize_svg_markup,
# même défense en profondeur que html.escape sur les autres kinds.
sanitized = sanitize_svg_markup(svg_markup)
media = (
f'<div class="docImage" data-element-id="{el["id"]}" data-kind="image"{media_style_attr}>{sanitized}</div>'
)
else:
src = html_lib.escape(str(a.get("src", "")))
alt = html_lib.escape(str(a.get("alt", "")))
if not src:
media = (
f'<div class="docImage docImagePlaceholder" data-element-id="{el["id"]}" '
f'data-kind="image"{media_style_attr}>Image — aucun fichier choisi</div>'
)
else:
media = (
f'<img class="docImage" data-element-id="{el["id"]}" data-kind="image" '
f'src="{src}" alt="{alt}"{media_style_attr}{loading_attr}>'
)
# Comportement au clic (mutuellement exclusif, voir panneau
# Propriétés) — "lien" ouvre une URL externe dans un nouvel onglet
# (jamais dans l'éditeur lui-même), "plein écran" ouvre un aperçu
# agrandi géré côté client (voir static/document/js/
# document-editor.js::forgeDocOpenImageLightbox), tous deux
# UNIQUEMENT actifs en Mode Aperçu (même principe que les mini-jeux
# et la pièce jointe d'un bouton). Reçoit le style de bloc UNIQUEMENT
# s'il n'y a pas de légende par-dessus (sinon c'est elle, plus
# englobante encore, qui le reçoit juste plus bas).
if click_behavior == "link" and link_url:
href = html_lib.escape(link_url)
wrapper_style_attr = f' style="{box_style}"' if (box_style and not caption) else ""
media = (
f'<a class="docImageLink" href="{href}" target="_blank" '
f'rel="noopener noreferrer"{wrapper_style_attr}>{media}</a>'
)
elif click_behavior == "lightbox":
wrapper_style_attr = f' style="{box_style}"' if (box_style and not caption) else ""
media = f'<div class="docImageLightboxTrigger"{wrapper_style_attr}>{media}</div>'
if caption:
# render_content_align (retour utilisateur du 26/09/2026 :
# "je peux augmenter la hauteur d'un conteneur mais pas
# l'alignement vertical à l'intérieur") n'a de sens ici QUE pour
# la figure (conteneur flex-colonne à plusieurs enfants réels —
# image + légende) : jamais sur l'<img> seul ni sur les
# enveloppes lien/plein écran, qui ne sont pas des conteneurs
# flex-colonne à plusieurs enfants.
figure_style = " ".join(p for p in (box_style, render_content_align(a)) if p)
figure_style_attr = f' style="{figure_style}"' if figure_style else ""
media = (
f'<figure class="docImageFigure"{figure_style_attr}>{media}'
f'<figcaption class="docImageCaption">{html_lib.escape(caption)}</figcaption></figure>'
)
return media
_LIST_STYLE_TYPES = {
"liste_puces": ("disc", "circle", "square", "none"),
"liste_numerotee": (
"decimal",
"decimal-leading-zero",
"lower-roman",
"upper-roman",
"lower-alpha",
"upper-alpha",
"none",
),
}
def _list_text_style(a: dict[str, Any]) -> list[str]:
"""Typographie de la liste ENTIÈRE (jamais par élément individuel,
voir element_kind_labels.py — portée actée avec l'utilisateur)."""
parts = []
if a.get("bold"):
parts.append("font-weight:700;")
if a.get("italic"):
parts.append("font-style:italic;")
if a.get("underline"):
parts.append("text-decoration:underline;")
font_family = str(a.get("font_family", "")).strip()
if font_family:
parts.append(f"font-family:{html_lib.escape(font_family)};")
font_size = str(a.get("font_size", "")).strip()
if font_size:
parts.append(f"font-size:{html_lib.escape(font_size)};")
line_height = str(a.get("line_height", "")).strip()
if line_height:
parts.append(f"line-height:{html_lib.escape(line_height)};")
text_color = str(a.get("text_color", "")).strip()
if text_color:
parts.append(f"color:{html_lib.escape(text_color)};")
return parts
def _list_marker_style(a: dict[str, Any], kind: str) -> list[str]:
"""Puces/numéros — `marker_color`/`marker_size` passent par des
PROPRIÉTÉS PERSONNALISÉES CSS (héritées jusqu'au pseudo-élément
`::marker` de chaque <li>, voir .docList li::marker dans
document-editor.css) : un style inline posé sur le <ul>/<ol> ne peut
pas cibler directement le `::marker` de ses enfants autrement."""
parts = []
list_style_type = str(a.get("list_style_type", ""))
if list_style_type in _LIST_STYLE_TYPES.get(kind, ()):
parts.append(f"list-style-type:{list_style_type};")
position_inside = str(a.get("list_style_position", "outside")) == "inside"
if position_inside:
parts.append("list-style-position:inside;")
# Retour utilisateur du 26/09/2026 : "si j'enlève les puces ou que les
# puces se mettent à l'intérieur, il reste un espace devant la liste,
# cet espace doit être supprimé" — le padding-left:1.4em par défaut
# (document-editor.css, .docList) réserve la place d'une puce
# EXTÉRIEURE ; il n'a plus lieu d'être dès que la puce n'est plus là
# ("none") ou qu'elle rejoint le flux du texte ("inside").
if list_style_type == "none" or position_inside:
parts.append("padding-left:0;")
marker_color = str(a.get("marker_color", "")).strip()
if marker_color:
parts.append(f"--doc-marker-color:{html_lib.escape(marker_color)};")
marker_size = str(a.get("marker_size", "")).strip()
if marker_size:
parts.append(f"--doc-marker-size:{html_lib.escape(marker_size)};")
# Puce personnalisée (image SVG) : liste à puces UNIQUEMENT, une
# puce imagée n'a pas de sens sur une liste numérotée. list-style-image
# prime visuellement sur list-style-type dès qu'il est posé (aucun
# conflit à gérer entre les deux).
svg_markup = str(a.get("svg_markup", "")).strip() if kind == "liste_puces" else ""
if svg_markup:
sanitized = sanitize_svg_markup(svg_markup)
encoded = urllib.parse.quote(sanitized)
parts.append(f'list-style-image:url("data:image/svg+xml,{encoded}");')
return parts
def _list_item_style(a: dict[str, Any]) -> list[str]:
"""`item_padding`/`item_spacing` s'appliquent à CHAQUE <li>, jamais au
conteneur <ul>/<ol> lui-même — même mécanisme de propriété
personnalisée CSS héritée que `_list_marker_style` ci-dessus (voir
.docList li dans document-editor.css). Une valeur UNIFORME partagée
par tous les éléments (retour utilisateur du 26/09/2026), jamais
réglable par élément individuel."""
parts = []
item_padding = str(a.get("item_padding", "")).strip()
if item_padding:
parts.append(f"--doc-item-padding:{html_lib.escape(item_padding)};")
item_spacing = str(a.get("item_spacing", "")).strip()
if item_spacing:
parts.append(f"--doc-item-spacing:{html_lib.escape(item_spacing)};")
return parts
def _render_list(el: dict[str, Any], _children_by_parent: dict[int | None, list[dict[str, Any]]]) -> str:
"""Liste à puces (<ul>) ou numérotée (<ol>) — le kind lui-même décide
la balise, pas un attribut "ordered" séparé (voir
element_kind_labels.element_default_attributes). Une "items" vide
rend une liste vide plutôt qu'un placeholder : contrairement à une
image sans fichier, une liste sans élément n'a rien d'anormal à
afficher (le créateur vient peut-être de tout supprimer avant d'en
retaper un)."""
a = el["attributes"]
kind = el["kind"]
items = a.get("items", [])
tag = "ol" if kind == "liste_numerotee" else "ul"
items_html = "".join(f"<li>{html_lib.escape(str(item))}</li>" for item in items)
style = " ".join(
_list_text_style(a)
+ _list_marker_style(a, kind)
+ _list_item_style(a)
+ [render_box_style(a), render_content_align(a)]
)
style = style.strip()
style_attr = f' style="{style}"' if style else ""
return f'<{tag} class="docList" data-element-id="{el["id"]}" data-kind="{kind}"{style_attr}>{items_html}</{tag}>'
_BUTTON_TEXT_TRANSFORMS = ("uppercase", "lowercase", "capitalize")
def _button_text_style(a: dict[str, Any]) -> list[str]:
"""Déclarations de typographie propres au bouton (jamais dans
box_style.py, partagé avec d'autres kinds qui n'ont pas tous une
notion de texte)."""
parts = []
if a.get("bold"):
# 800 (jamais 700, déjà le poids par défaut du CSS de base) :
# "gras" ne fait que RENFORCER le poids existant, jamais
# l'affaiblir — aucun bouton déjà créé ne change d'apparence tant
# que cette case n'est pas cochée explicitement.
parts.append("font-weight:800;")
if a.get("italic"):
parts.append("font-style:italic;")
text_transform = str(a.get("text_transform", "none"))
if text_transform in _BUTTON_TEXT_TRANSFORMS:
parts.append(f"text-transform:{text_transform};")
font_family = str(a.get("font_family", "")).strip()
if font_family:
parts.append(f"font-family:{html_lib.escape(font_family)};")
font_size = str(a.get("font_size", "")).strip()
if font_size:
parts.append(f"font-size:{html_lib.escape(font_size)};")
letter_spacing = str(a.get("letter_spacing", "")).strip()
if letter_spacing:
parts.append(f"letter-spacing:{html_lib.escape(letter_spacing)};")
text_color = str(a.get("text_color", "")).strip()
if text_color:
parts.append(f"color:{html_lib.escape(text_color)};")
return parts
def _button_icon_html(a: dict[str, Any]) -> str:
svg_markup = str(a.get("svg_markup", "")).strip()
if not svg_markup:
return ""
icon_size = str(a.get("icon_size", "")).strip()
size_style = (
f' style="width:{html_lib.escape(icon_size)}; height:{html_lib.escape(icon_size)};"' if icon_size else ""
)
return f'<span class="docButtonIcon"{size_style}>{sanitize_svg_markup(svg_markup)}</span>'
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 ""
# `data-attachment-filename` sert UNIQUEMENT de marqueur mécanique : un
# fichier a bien été joint (voir routes/document/
# document_element_upload_attachment.py). L'URL de téléchargement
# elle-même n'est jamais construite ici (ce renderer ne connaît pas le
# slug du support) — static/document/js/document-editor.js l'assemble
# à partir de `data-element-id` + FORGE_DOCUMENT.slug, même principe
# que le reste des appels AJAX de l'éditeur.
attachment_filename = html_lib.escape(str(a.get("attachment_filename", "")))
attachment_attr = f' data-attachment-filename="{attachment_filename}"' if attachment_filename else ""
style = " ".join(_button_text_style(a) + [render_box_style(a)]).strip()
style_attr = f' style="{style}"' if style else ""
icon_html = _button_icon_html(a)
label_span = f'<span class="docButtonLabel">{label}</span>'
inner = (
f"{label_span}{icon_html}" if str(a.get("icon_position", "before")) == "after" else f"{icon_html}{label_span}"
)
return (
f'<button type="button" class="docButton" data-element-id="{el["id"]}" '
f'data-kind="bouton"{target_attr}{attachment_attr}{style_attr}>{inner}</button>'
)
def _render_badge(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", "")))
svg_markup = str(a.get("svg_markup", "")).strip()
icon_html = f'<span class="docBadgeIcon">{sanitize_svg_markup(svg_markup)}</span>' if svg_markup else ""
style_parts = []
width = str(a.get("width", "")).strip()
if width:
# Fixer une largeur implique de ne plus s'étirer sur toute la
# largeur de .docPageContent (comportement par défaut d'un enfant
# flex en colonne, voir static/document/document-editor.css) —
# les deux vont toujours ensemble, jamais l'un sans l'autre.
style_parts.append(f"align-self:flex-start; width:{html_lib.escape(width)};")
border_radius = str(a.get("border_radius", "")).strip()
if border_radius:
style_parts.append(f"border-radius:{html_lib.escape(border_radius)};")
if a.get("bold"):
style_parts.append("font-weight:800;")
if a.get("uppercase"):
style_parts.append("text-transform:uppercase;")
style_attr = f' style="{" ".join(style_parts)}"' if style_parts else ""
return (
f'<div class="docBadge" data-element-id="{el["id"]}" data-kind="badge"{style_attr}>{icon_html}{content}</div>'
)
def _render_carte(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", "")))
title = html_lib.escape(str(a.get("title", "")))
description = html_lib.escape(str(a.get("description", "")))
return (
f'<div class="docCard" data-element-id="{el["id"]}" data-kind="carte">'
f'<div class="docCardLabel">{label}</div>'
f'<div class="docCardTitle">{title}</div>'
f'<div class="docCardDescription">{description}</div>'
f"</div>"
)
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,
"titre": _render_text,
"paragraphe": _render_text,
"image": _render_image,
"bouton": _render_button,
"liste_puces": _render_list,
"liste_numerotee": _render_list,
"badge": _render_badge,
"carte": _render_carte,
"quiz": _render_quiz,
"association": _render_association,
"memory": _render_memory,
"mots": _render_mots,
"scenario": _render_scenario,
"zones": _render_minigame_placeholder,
}