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>
885 lines
42 KiB
Python
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,
|
|
}
|