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
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:
co-authored by
Claude Sonnet 5
parent
7e4a761cbb
commit
bab581737a
@@ -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",
|
||||
]
|
||||
@@ -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()
|
||||
@@ -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 {}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user