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 (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}" 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'{alt}' ) # 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: figure_style_attr = f' style="{box_style}"' if box_style else "" media = ( f'
{media}' f'
{html_lib.escape(caption)}
' ) return media def _render_list(el: dict[str, Any], _children_by_parent: dict[int | None, list[dict[str, Any]]]) -> str: """Liste à puces (