import html as html_lib import json import random from typing import Any from .box_style import render_box_style 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 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)};" box_style = render_box_style(a) if box_style: style += f" {box_style}" 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. 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 (