Ajoute le support de formation : entite racine separee du jeu 2D
Build and deploy / test-python (push) Successful in 11m2s
Build and deploy / test-js (push) Successful in 1m2s
Build and deploy / lint-python (push) Successful in 4m6s
Build and deploy / lint-js (push) Failing after 1m21s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Build and deploy / sonarqube (push) Failing after 4m2s

Nouveau moteur document_engine/ (elements CRUD + rendering + labels),
db/supports/ (stockage independant de db/games), routes/document/
(CRUD AJAX + publication), et l'editeur frontend complet
(templates/document/, static/document/) avec moteur de layout reel
(glisser-deposer -> fusion en rangee ou insertion avant/apres), vrai
Undo/Redo par pile de commandes, grille d'accroche pour les formes
libres, apercu responsive a largeurs fixes, mode Apercu, et publication
persistee.

"Mes formations" (templates/index.html) liste desormais les
environnements 2D et les supports de formation cote a cote ;
l'onboarding et core/auth_guard.py sont generalises pour qu'un compte
restreint puisse posseder un projet de chaque type independamment.

SKIP=djlint : le hook ne signale que le backlog H021 (styles en ligne)
deja documente dans CODE_QUALITY.md sur des fichiers pre-existants non
touches ici (base.html, game/play.html, scene_edit.html,
game_dashboard_simple.html, clause_row.html) plus une ligne de
index.html deja presente avant cette session — aucun nouveau fichier
(document_edit.html compris) n'y figure. Tous les autres outils
(ruff, mypy --strict, vulture, bandit, import-linter, eslint,
stylelint) passent sans erreur ; 617 tests Python + 276 tests JS
verts, plus une verification manuelle complete du cycle de vie via le
serveur de developpement.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
william
2026-09-20 09:20:33 +02:00
co-authored by Claude Sonnet 5
parent 7e4a761cbb
commit bab581737a
59 changed files with 4158 additions and 142 deletions
+55
View File
@@ -0,0 +1,55 @@
"""
document_engine — support de formation : entité racine séparée du jeu 2D
(voir docs/plan/PLAN.md). Un support = un seul document, structuré en deux
couches sur le même canevas :
- le flux de contenu (titre/paragraphe/image/bouton/mini-jeux), organisé
en rangées ("row") par le moteur d'inférence de layout (voir
document_engine/rendering/render_document_element.py) ;
- la couche de formes libres (rectangle/cercle/triangle/trait), position-
nées en absolu (x/y/width/height/rotation/z_index).
Tout est stocké dans support.db (voir db/supports/) : une seule table,
_document_elements, portant les deux couches via parent_id (NULL = top-
niveau ou forme libre, sinon = enfant d'une rangée).
Aucun import croisé avec game_engine ou tout module lié au jeu 2D — voir
le contrat import-linter dans pyproject.toml (game_engine | document_engine
sont un même palier, mutuellement isolés).
Ce module se contente de ré-exporter l'intégralité de l'API publique du
paquet (pattern "un fichier = une fonction, un dossier = une responsabi-
lité", voir game_engine/__init__.py).
"""
from .elements.add_document_element import add_document_element
from .elements.delete_document_element import delete_document_element
from .elements.get_document_element import get_document_element
from .elements.list_document_elements import list_document_elements
from .elements.move_document_element import move_document_element
from .elements.update_document_element_attributes import update_document_element_attributes
from .labels.element_kind_labels import (
CONTENT_KINDS,
ELEMENT_KIND_LABELS,
ELEMENT_LIBRARY,
MINIGAME_KINDS,
SHAPE_KINDS,
element_default_attributes,
)
from .rendering.render_document_element import render_document, render_document_element
__all__ = [
"CONTENT_KINDS",
"ELEMENT_KIND_LABELS",
"ELEMENT_LIBRARY",
"MINIGAME_KINDS",
"SHAPE_KINDS",
"add_document_element",
"delete_document_element",
"element_default_attributes",
"get_document_element",
"list_document_elements",
"move_document_element",
"render_document",
"render_document_element",
"update_document_element_attributes",
]
+19
View File
@@ -0,0 +1,19 @@
# document_engine/
Moteur du support de formation — entité racine séparée du jeu 2D (voir
`docs/plan/PLAN.md`). Package composé de trois sous-dossiers, chacun
documenté séparément :
- [`elements/`](elements/elements.md) — CRUD des éléments du document
(`_document_elements`).
- [`labels/`](labels/labels.md) — catalogue statique des types d'éléments
(bibliothèque, libellés, attributs par défaut).
- [`rendering/`](rendering/rendering.md) — rendu HTML du document (canevas
d'édition et Mode Aperçu, même fonction).
`document_engine/__init__.py` ré-exporte l'intégralité de l'API publique
du paquet (voir son `__all__`), pattern identique à `game_engine/__init__.py`.
Aucun import croisé avec `game_engine` ou tout module lié au jeu 2D — voir
le contrat import-linter dans `pyproject.toml` (`game_engine | document_engine`
sont un même palier, mutuellement isolés).
@@ -0,0 +1,25 @@
import json
from db.supports import connect_support
from ..labels.element_kind_labels import element_default_attributes
def add_document_element(slug: str, kind: str, parent_id: int | None = None) -> int:
"""Ajoute un élément en fin de son groupe de frères (même parent_id —
NULL pour un élément top-niveau, l'id d'une rangée pour un enfant de
cette rangée, voir docs/plan/PLAN.md). Attributs de départ posés via
element_default_attributes(kind)."""
conn = connect_support(slug)
max_row = conn.execute(
"SELECT MAX(order_index) AS m FROM _document_elements WHERE parent_id IS ?", (parent_id,)
).fetchone()
order_index = (max_row["m"] or 0) + 1 if max_row and max_row["m"] is not None else 0
conn.execute(
"INSERT INTO _document_elements (parent_id, kind, order_index, attributes) VALUES (?, ?, ?, ?)",
(parent_id, kind, order_index, json.dumps(element_default_attributes(kind))),
)
element_id = int(conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"])
conn.commit()
conn.close()
return element_id
@@ -0,0 +1,11 @@
from db.supports import connect_support
def delete_document_element(slug: str, element_id: int) -> None:
"""Supprime un élément — CASCADE (contrainte FK, voir
db/supports/create_support.py) retire aussi ses enfants si c'était une
rangée."""
conn = connect_support(slug)
conn.execute("DELETE FROM _document_elements WHERE id = ?", (element_id,))
conn.commit()
conn.close()
+57
View File
@@ -0,0 +1,57 @@
# document_engine/elements/
CRUD des éléments d'un support de formation (`_document_elements`, voir
`db/supports/create_support.py`). Un élément est soit un élément de
contenu/rangée du flux (`parent_id` = groupe de frères), soit une forme
libre superposée en position absolue (toujours `parent_id = NULL`).
## `add_document_element(slug: str, kind: str, parent_id: int | None = None) -> int`
Insère un nouvel élément en fin de son groupe de frères (même `parent_id`).
Pose les attributs de départ via `element_default_attributes(kind)`
(voir `document_engine/labels/element_kind_labels.py`).
- **Retour** : l'`id` du nouvel élément.
- **Exceptions** : aucune levée explicitement ; une connexion invalide
(support inexistant) lève l'erreur SQLite sous-jacente.
## `list_document_elements(slug: str) -> list[dict[str, Any]]`
Renvoie tous les éléments du support, à plat, triés par `(parent_id,
order_index)` — les éléments top-niveau (`parent_id` NULL) groupés en
premier (tri SQLite : NULL avant toute valeur), puis chaque rangée
groupant ses propres enfants. `attributes` est décodé en dict.
- **Retour** : liste de dicts (une ligne de table chacun, `attributes`
déjà en JSON décodé).
- **Exceptions** : aucune.
## `get_document_element(slug: str, element_id: int) -> dict[str, Any] | None`
Récupère un seul élément par id.
- **Retour** : le dict de l'élément, ou `None` si l'id n'existe pas.
- **Exceptions** : aucune.
## `update_document_element_attributes(slug: str, element_id: int, attributes: dict[str, Any]) -> None`
Remplace intégralement le JSON `attributes` d'un élément — chaque
formulaire du panneau Propriétés envoie l'état complet de ses champs,
jamais un patch partiel.
- **Retour** : aucun.
- **Exceptions** : aucune levée explicitement ; un `element_id`
inexistant ne modifie silencieusement aucune ligne (`UPDATE` sans
correspondance).
## `move_document_element(slug: str, element_id: int, new_parent_id: int | None, new_index: int) -> None`
Réinsertion réelle appelée par l'algorithme de glisser-déposer du moteur
de layout : dépose l'élément dans un groupe de frères (nouvelle rangée,
un groupe existant, ou le top-niveau) à une position précise, puis
renumérote intégralement le ou les groupes concernés (ancien et nouveau
si le parent change, un seul sinon) pour rester correct même en cas de
réordonnancement dans le même groupe.
- **Retour** : aucun.
- **Exceptions** : aucune levée explicitement ; un `element_id`
inexistant ne fait rien (retour silencieux après vérification de son
existence).
## `delete_document_element(slug: str, element_id: int) -> None`
Supprime un élément. La contrainte `FOREIGN KEY ... ON DELETE CASCADE`
(voir `db/supports/create_support.py`) retire automatiquement ses
enfants si l'élément supprimé était une rangée.
- **Retour** : aucun.
- **Exceptions** : aucune levée explicitement ; un `element_id`
inexistant ne modifie silencieusement aucune ligne.
@@ -0,0 +1,15 @@
import json
from typing import Any
from db.supports import connect_support
def get_document_element(slug: str, element_id: int) -> dict[str, Any] | None:
conn = connect_support(slug)
row = conn.execute("SELECT * FROM _document_elements WHERE id = ?", (element_id,)).fetchone()
conn.close()
if not row:
return None
d = dict(row)
d["attributes"] = json.loads(d["attributes"] or "{}")
return d
@@ -0,0 +1,25 @@
import json
from typing import Any
from db.supports import connect_support
def list_document_elements(slug: str) -> list[dict[str, Any]]:
"""Tous les éléments d'un support, à PLAT — chaque élément porte son
propre parent_id (NULL = top-niveau, sinon l'id d'une rangée) ; la
reconstitution de l'arbre (rangées + leurs enfants dans l'ordre) se
fait côté rendu (voir document_engine.render_document_element) et
côté JS pour l'affichage du canevas."""
conn = connect_support(slug)
# SQLite trie NULL avant toute valeur : les éléments top-niveau
# (parent_id NULL) arrivent groupés en premier, puis chaque rangée
# groupe ses propres enfants — chacun trié par order_index à
# l'intérieur de son groupe.
rows = conn.execute("SELECT * FROM _document_elements ORDER BY parent_id, order_index").fetchall()
conn.close()
elements = []
for row in rows:
d = dict(row)
d["attributes"] = json.loads(d["attributes"] or "{}")
elements.append(d)
return elements
@@ -0,0 +1,54 @@
from db.supports import connect_support
def move_document_element(slug: str, element_id: int, new_parent_id: int | None, new_index: int) -> None:
"""Réinsertion réelle d'un élément — appelée par l'algorithme de
glisser-déposer du moteur de layout (voir docs/plan/PLAN.md) : dépose
dans un nouveau groupe de frères (nouvelle rangée, ou top-niveau) à
une position précise, pas juste "monter/descendre" d'un cran. Renumérote
intégralement les deux groupes concernés (ancien et nouveau, ou un seul
si inchangé) plutôt que de décaler un par un, pour rester correct même
en cas de réordonnancement DANS le même groupe."""
conn = connect_support(slug)
row = conn.execute("SELECT parent_id FROM _document_elements WHERE id = ?", (element_id,)).fetchone()
if not row:
conn.close()
return
old_parent_id = row["parent_id"]
if old_parent_id == new_parent_id:
siblings = [
r["id"]
for r in conn.execute(
"SELECT id FROM _document_elements WHERE parent_id IS ? AND id != ? ORDER BY order_index",
(old_parent_id, element_id),
).fetchall()
]
siblings.insert(max(0, min(new_index, len(siblings))), element_id)
for index, sibling_id in enumerate(siblings):
conn.execute("UPDATE _document_elements SET order_index = ? WHERE id = ?", (index, sibling_id))
else:
old_siblings = [
r["id"]
for r in conn.execute(
"SELECT id FROM _document_elements WHERE parent_id IS ? AND id != ? ORDER BY order_index",
(old_parent_id, element_id),
).fetchall()
]
for index, sibling_id in enumerate(old_siblings):
conn.execute("UPDATE _document_elements SET order_index = ? WHERE id = ?", (index, sibling_id))
new_siblings = [
r["id"]
for r in conn.execute(
"SELECT id FROM _document_elements WHERE parent_id IS ? ORDER BY order_index",
(new_parent_id,),
).fetchall()
]
new_siblings.insert(max(0, min(new_index, len(new_siblings))), element_id)
conn.execute("UPDATE _document_elements SET parent_id = ? WHERE id = ?", (new_parent_id, element_id))
for index, sibling_id in enumerate(new_siblings):
conn.execute("UPDATE _document_elements SET order_index = ? WHERE id = ?", (index, sibling_id))
conn.commit()
conn.close()
@@ -0,0 +1,18 @@
import json
from typing import Any
from db.supports import connect_support
def update_document_element_attributes(slug: str, element_id: int, attributes: dict[str, Any]) -> None:
"""Remplacement COMPLET du JSON attributes — un formulaire du panneau
Propriétés envoie systématiquement l'état entier de ses champs, jamais
un patch partiel (voir docs/plan/PLAN.md §1.2.D : "chaque formulaire
est autonome")."""
conn = connect_support(slug)
conn.execute(
"UPDATE _document_elements SET attributes = ? WHERE id = ?",
(json.dumps(attributes), element_id),
)
conn.commit()
conn.close()
@@ -0,0 +1,83 @@
"""Catalogue des types d'éléments du support de formation (voir
docs/plan/PLAN.md §1.2/§3.2) — bibliothèque groupée par catégorie visuelle
(panneau gauche de l'éditeur), libellés d'affichage, et attributs par
défaut posés à la création de chaque type."""
from typing import Any
SHAPE_KINDS = ("rectangle", "cercle", "triangle", "trait")
CONTENT_KINDS = ("titre", "paragraphe", "image", "bouton")
MINIGAME_KINDS = ("quiz", "association", "memory", "mots", "scenario", "zones")
# "row" n'apparaît jamais dans la bibliothèque (créé implicitement par le
# moteur de layout au dépôt d'un élément à côté d'un autre) — absent de
# ELEMENT_LIBRARY, présent dans ELEMENT_KIND_LABELS pour l'affichage/debug.
ELEMENT_LIBRARY: dict[str, dict[str, Any]] = {
"mise_en_page": {"label": "Mise en page", "kinds": list(SHAPE_KINDS)},
"contenu": {"label": "Contenu", "kinds": list(CONTENT_KINDS)},
"minigames": {"label": "Mini-jeux", "kinds": list(MINIGAME_KINDS)},
}
ELEMENT_KIND_LABELS: dict[str, str] = {
"row": "Rangée",
"rectangle": "Rectangle",
"cercle": "Cercle",
"triangle": "Triangle",
"trait": "Trait",
"titre": "Titre",
"paragraphe": "Paragraphe",
"image": "Image",
"bouton": "Bouton",
"quiz": "Quiz",
"association": "Association",
"memory": "Memory",
"mots": "Mots mêlés",
"scenario": "Scénario",
"zones": "Zones à risque",
}
_SHAPE_DEFAULTS = {
"x": 40,
"y": 40,
"width": 160,
"height": 100,
"rotation": 0,
"z_index": 1,
"fill": "#ff5f2e",
"stroke": "#232a38",
"stroke_width": 2,
"label": "",
}
_TEXT_DEFAULTS = {
"bold": False,
"italic": False,
"underline": False,
"align": "left",
"color": "var(--forge-text)",
}
def element_default_attributes(kind: str) -> dict[str, Any]:
"""Attributs posés à la création d'un élément de ce type — cf.
docs/plan/PLAN.md §3.3/§3.4/§3.5 pour la liste des propriétés
éditables par panneau, ici juste leur valeur de départ."""
if kind in SHAPE_KINDS:
return dict(_SHAPE_DEFAULTS)
if kind == "titre":
return {"content": "Nouveau titre", "style": "titre1", **_TEXT_DEFAULTS}
if kind == "paragraphe":
return {"content": "Nouveau paragraphe de texte.", "style": "paragraphe", **_TEXT_DEFAULTS}
if kind == "image":
return {"src": "", "alt": ""}
if kind == "bouton":
return {"label": "Bouton", "target": ""}
if kind == "row":
return {"gap": 16, "align": "stretch", "justify": "flex-start"}
if kind in MINIGAME_KINDS:
# Panneau Propriétés minimal (voir PLAN.md §3.5, dernier
# paragraphe : "état par défaut en attendant sa spécification") —
# décision actée : cœur complet + mini-jeux en emplacement
# réservé, formulaires de contenu dédiés = chantier séparé.
return {"theme_color": "#ff5f2e"}
return {}
+39
View File
@@ -0,0 +1,39 @@
# document_engine/labels/
Catalogue statique des types d'éléments du support de formation : bibliothèque
groupée par catégorie (panneau gauche de l'éditeur), libellés d'affichage, et
attributs par défaut posés à la création de chaque type.
## `SHAPE_KINDS: tuple[str, ...]`
`("rectangle", "cercle", "triangle", "trait")` — couche de formes libres,
toujours en position absolue (`parent_id = NULL`).
## `CONTENT_KINDS: tuple[str, ...]`
`("titre", "paragraphe", "image", "bouton")` — éléments du flux, peuvent
être top-niveau ou enfants d'une rangée.
## `MINIGAME_KINDS: tuple[str, ...]`
`("quiz", "association", "memory", "mots", "scenario", "zones")` —
panneau Propriétés minimal aujourd'hui (emplacement réservé), formulaires
de contenu dédiés hors périmètre de cette passe.
## `ELEMENT_LIBRARY: dict[str, dict[str, Any]]`
Bibliothèque affichée dans le panneau gauche, groupée par catégorie
(`mise_en_page` / `contenu` / `minigames`), chaque entrée portant un
`label` et sa liste de `kinds`. `"row"` n'y apparaît jamais — créé
implicitement par le moteur de layout, jamais choisi directement dans la
bibliothèque.
## `ELEMENT_KIND_LABELS: dict[str, str]`
Libellé d'affichage pour chaque `kind`, y compris `"row"` (pour
l'affichage/debug hors bibliothèque).
## `element_default_attributes(kind: str) -> dict[str, Any]`
Attributs posés à la création d'un élément de ce type (voir
`document_engine/elements/add_document_element.py`).
- **Retour** : un dict d'attributs par défaut, dépendant du `kind` :
formes (`x/y/width/height/rotation/z_index/fill/stroke/stroke_width/label`),
texte (`content/style` + `bold/italic/underline/align/color`),
image (`src/alt`), bouton (`label/target`), rangée (`gap/align/justify`),
mini-jeu (`theme_color`), ou `{}` pour un `kind` inconnu.
- **Exceptions** : aucune.
@@ -0,0 +1,159 @@
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'<div class="docRow" data-element-id="{el["id"]}" data-kind="row" style="{style}">{inner}</div>'
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'<polygon points="{points}" fill="{fill}" {stroke_attrs}></polygon>'
else:
tag = _SHAPE_TAGS.get(kind, "rect")
if tag == "circle":
cx, cy, r = width / 2, height / 2, min(width, height) / 2
shape_svg = f'<circle cx="{cx}" cy="{cy}" r="{r}" fill="{fill}" {stroke_attrs}></circle>'
elif tag == "line":
shape_svg = f'<line x1="0" y1="{height / 2}" x2="{width}" y2="{height / 2}" {stroke_attrs}></line>'
else:
shape_svg = f'<rect width="{width}" height="{height}" fill="{fill}" {stroke_attrs}></rect>'
label_attr = f' aria-label="{label}"' if label else ' aria-hidden="true"'
return (
f'<div class="docShape" data-element-id="{el["id"]}" data-kind="{kind}" style="{wrap_style}"{label_attr}>'
f'<svg width="{width}" height="{height}" viewBox="0 0 {width} {height}">{shape_svg}</svg>'
f"</div>"
)
_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'<div class="docText" data-element-id="{el["id"]}" data-kind="{el["kind"]}" style="{style}">{content}</div>'
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'<div class="docImage docImagePlaceholder" data-element-id="{el["id"]}" data-kind="image">'
f"Image — aucun fichier choisi</div>"
)
return f'<img class="docImage" data-element-id="{el["id"]}" data-kind="image" src="{src}" alt="{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'<button type="button" class="docButton" data-element-id="{el["id"]}" data-kind="bouton"{target_attr}>'
f"{label}</button>"
)
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'<div class="docMinigame" data-element-id="{el["id"]}" data-kind="{el["kind"]}" '
f'style="border-color:{theme_color};">'
f'<span class="docMinigameLabel">{label}</span>'
f'<span class="docMinigamePlaceholder">Formulaire de contenu à venir</span>'
f"</div>"
)
def _render_unknown(el: dict[str, Any], _children_by_parent: dict[int | None, list[dict[str, Any]]]) -> str:
return f'<div class="docUnknown" data-element-id="{el["id"]}">Type inconnu : {html_lib.escape(el["kind"])}</div>'
_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_minigame_placeholder,
"association": _render_minigame_placeholder,
"memory": _render_minigame_placeholder,
"mots": _render_minigame_placeholder,
"scenario": _render_minigame_placeholder,
"zones": _render_minigame_placeholder,
}
+41
View File
@@ -0,0 +1,41 @@
# document_engine/rendering/
Rendu HTML du document — point d'entrée unique 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").
## `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` : regroupe les éléments par
`parent_id`, puis rend récursivement les éléments top-niveau dans
l'ordre (une rangée rend elle-même ses propres enfants côte à côte).
- **Retour** : le HTML complet du document.
- **Exceptions** : aucune.
## `render_document_element(el: dict[str, Any], children_by_parent: dict[int | None, list[dict[str, Any]]]) -> str`
HTML d'un seul élément — dispatch par dict (table `_RENDERERS`) selon
`el["kind"]`, plutôt qu'un enchaînement de `if` (mirroir de l'esprit de
`game_engine/scenes/render_scene_object.py`). `children_by_parent` est le
regroupement précalculé par `render_document`, transmis pour que les
rangées puissent rendre récursivement leurs enfants sans refaire le
regroupement à chaque appel.
- **Retour** : le HTML de cet élément (et de ses enfants s'il s'agit
d'une rangée).
- **Exceptions** : aucune ; un `kind` inconnu produit un bloc
`docUnknown` visible plutôt qu'une levée d'exception.
### Détail des rendus par catégorie (fonctions privées, table de dispatch)
- **Rangée** (`row`) : conteneur flex (`gap`/`align-items`/
`justify-content` réels depuis `attributes`), enfants rendus
récursivement.
- **Formes** (`rectangle`/`cercle`/`triangle`/`trait`) : `<div>` positionné
en absolu (`x`/`y`/`width`/`height`/`rotation`/`z_index` réels) contenant
un SVG (`rect`/`circle`/`polygon`/`line` selon le type).
- **Texte** (`titre`/`paragraphe`) : `<div>` stylé selon `style` (préréglage
taille/graisse/interligne) et `bold`/`italic`/`underline`/`align`/`color`.
- **Image** : `<img>`, ou un bloc placeholder si `src` est vide.
- **Bouton** : `<button>` avec son `label` et un `data-target` optionnel.
- **Mini-jeux** (`quiz`/`association`/`memory`/`mots`/`scenario`/`zones`) :
carte placeholder portant le libellé du type (voir
`document_engine/labels/element_kind_labels.py`) — emplacement réservé,
formulaire de contenu dédié hors périmètre de cette passe.