import html as html_lib import json import random from typing import Any 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") 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};" ) # max_width optionnel (ex. "60ch", "480px") — vide par défaut (pleine # largeur de .docPageContent, comportement inchangé). Retour # utilisateur du 24/09/2026 : un paragraphe doit pouvoir rester plus # étroit que la page, comme un sous-titre sous un grand titre, sans # dépendre d'une rangée (qui partagerait la largeur avec un frère). max_width = str(a.get("max_width", "")).strip() if max_width: style += f" max-width:{html_lib.escape(max_width)};" return f'
{content}
' def _render_image(el: dict[str, Any], _children_by_parent: dict[int | None, list[dict[str, Any]]]) -> str: a = el["attributes"] 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. from .sanitize_svg_markup import sanitize_svg_markup sanitized = sanitize_svg_markup(svg_markup) return f'
{sanitized}
' 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_list(el: dict[str, Any], _children_by_parent: dict[int | None, list[dict[str, Any]]]) -> str: """Liste à puces (