import html as html_lib from typing import Any _SHAPE_TAGS = {"rectangle": "rect", "cercle": "circle", "trait": "line"} def render_document(elements: list[dict[str, Any]]) -> str: """Assemble le document ENTIER à partir de la liste à plat renvoyée par document_engine.list_document_elements : les éléments top-niveau (parent_id NULL) dans l'ordre, chaque rangée ("row") rendant récursivement ses propres enfants côte à côte. Point d'entrée unique du rendu, utilisé à la fois par le canevas d'édition et par le Mode Aperçu (même fonction, voir docs/plan/PLAN.md — "Aperçu réel").""" children_by_parent: dict[int | None, list[dict[str, Any]]] = {} for el in elements: children_by_parent.setdefault(el["parent_id"], []).append(el) top_level = children_by_parent.get(None, []) return "".join(render_document_element(el, children_by_parent) for el in top_level) def render_document_element(el: dict[str, Any], children_by_parent: dict[int | None, list[dict[str, Any]]]) -> str: """HTML d'UN élément — dispatch par dict plutôt qu'un enchaînement de `if` (mirroir de l'esprit de game_engine/scenes/render_scene_object.py, mais un vrai registre ici puisque le nombre de kinds est plus élevé).""" renderer = _RENDERERS.get(el["kind"], _render_unknown) return renderer(el, children_by_parent) def _render_row(el: dict[str, Any], children_by_parent: dict[int | None, list[dict[str, Any]]]) -> str: attrs = el["attributes"] gap = attrs.get("gap", 16) align = html_lib.escape(str(attrs.get("align", "stretch"))) justify = html_lib.escape(str(attrs.get("justify", "flex-start"))) style = f"display:flex; flex-wrap:wrap; gap:{gap}px; align-items:{align}; justify-content:{justify};" children = children_by_parent.get(el["id"], []) inner = "".join(render_document_element(child, children_by_parent) for child in children) return f'
{inner}
' def _render_shape(el: dict[str, Any], _children_by_parent: dict[int | None, list[dict[str, Any]]]) -> str: a = el["attributes"] x, y, width, height = a["x"], a["y"], a["width"], a["height"] rotation, z_index = a.get("rotation", 0), a.get("z_index", 1) fill = html_lib.escape(str(a.get("fill", "#ff5f2e"))) stroke = html_lib.escape(str(a.get("stroke", "#232a38"))) stroke_width = a.get("stroke_width", 2) label = html_lib.escape(str(a.get("label", ""))) wrap_style = ( f"position:absolute; left:{x}px; top:{y}px; width:{width}px; height:{height}px; " f"transform:rotate({rotation}deg); z-index:{z_index};" ) kind = el["kind"] stroke_attrs = f'stroke="{stroke}" stroke-width="{stroke_width}"' if kind == "triangle": points = f"{width / 2},0 {width},{height} 0,{height}" shape_svg = f'' else: tag = _SHAPE_TAGS.get(kind, "rect") if tag == "circle": cx, cy, r = width / 2, height / 2, min(width, height) / 2 shape_svg = f'' elif tag == "line": shape_svg = f'' else: shape_svg = f'' label_attr = f' aria-label="{label}"' if label else ' aria-hidden="true"' return ( f'
' f'{shape_svg}' f"
" ) _STYLE_PRESETS = { "titre1": ("clamp(1.6rem,4vw,2rem)", 800, 1.15), "titre2": ("1.3rem", 800, 1.25), "paragraphe": ("15px", 400, 1.5), "legende": ("12.5px", 600, 1.4), } def _render_text(el: dict[str, Any], _children_by_parent: dict[int | None, list[dict[str, Any]]]) -> str: a = el["attributes"] content = html_lib.escape(str(a.get("content", ""))) style_name = a.get("style", "paragraphe") font_size, base_weight, line_height = _STYLE_PRESETS.get(style_name, _STYLE_PRESETS["paragraphe"]) weight = 800 if a.get("bold") else base_weight font_style = "italic" if a.get("italic") else "normal" text_decoration = "underline" if a.get("underline") else "none" align = html_lib.escape(str(a.get("align", "left"))) color = html_lib.escape(str(a.get("color", "var(--forge-text)"))) style = ( f"font-size:{font_size}; font-weight:{weight}; line-height:{line_height}; " f"font-style:{font_style}; text-decoration:{text_decoration}; text-align:{align}; color:{color};" ) return f'
{content}
' def _render_image(el: dict[str, Any], _children_by_parent: dict[int | None, list[dict[str, Any]]]) -> str: a = el["attributes"] src = html_lib.escape(str(a.get("src", ""))) alt = html_lib.escape(str(a.get("alt", ""))) if not src: return ( f'
' f"Image — aucun fichier choisi
" ) return f'{alt}' def _render_button(el: dict[str, Any], _children_by_parent: dict[int | None, list[dict[str, Any]]]) -> str: a = el["attributes"] label = html_lib.escape(str(a.get("label", "Bouton"))) target = html_lib.escape(str(a.get("target", ""))) target_attr = f' data-target="{target}"' if target else "" return ( f'" ) 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'
' f'{label}' f'Formulaire de contenu à venir' f"
" ) def _render_quiz(el: dict[str, Any], _children_by_parent: dict[int | None, list[dict[str, Any]]]) -> str: from ..labels.quiz_config import quiz_total_points, sanitize_quiz_config config = sanitize_quiz_config(el["attributes"]) theme_color = html_lib.escape(str(config["theme_color"])) question_count = len(config["questions"]) question_label = "question" if question_count <= 1 else "questions" total_points = quiz_total_points(config) timer_note = f" · ⏱ {config['timer_seconds']}s/question" if config["timer_enabled"] else "" subtitle = f"{question_count} {question_label} · {total_points} points{timer_note}" return ( f'
' f'Quiz' f'{html_lib.escape(subtitle)}' f"
" ) def _render_unknown(el: dict[str, Any], _children_by_parent: dict[int | None, list[dict[str, Any]]]) -> str: return f'
Type inconnu : {html_lib.escape(el["kind"])}
' _RENDERERS = { "row": _render_row, "rectangle": _render_shape, "cercle": _render_shape, "triangle": _render_shape, "trait": _render_shape, "titre": _render_text, "paragraphe": _render_text, "image": _render_image, "bouton": _render_button, "quiz": _render_quiz, "association": _render_minigame_placeholder, "memory": _render_minigame_placeholder, "mots": _render_minigame_placeholder, "scenario": _render_minigame_placeholder, "zones": _render_minigame_placeholder, }