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,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