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'
{inner}
'
_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'
{content}
'
_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'/
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'
{sanitized}
'
)
else:
src = html_lib.escape(str(a.get("src", "")))
alt = html_lib.escape(str(a.get("alt", "")))
if not src:
media = (
f'
Image — aucun fichier choisi
'
)
else:
media = (
f''
)
# 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'{media}'
)
elif click_behavior == "lightbox":
wrapper_style_attr = f' style="{box_style}"' if (box_style and not caption) else ""
media = f'
{media}
'
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' 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'{media}'
f'{html_lib.escape(caption)}'
)
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
, voir .docList li::marker dans
document-editor.css) : un style inline posé sur le
/ 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
, jamais au
conteneur
/ 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 (
) ou numérotée () — 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"
{html_lib.escape(str(item))}
" 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'{sanitize_svg_markup(svg_markup)}'
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'{label}'
inner = (
f"{label_span}{icon_html}" if str(a.get("icon_position", "before")) == "after" else f"{icon_html}{label_span}"
)
return (
f''
)
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'{sanitize_svg_markup(svg_markup)}' 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'
"
)
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