diff --git a/db/__init__.py b/db/__init__.py
index 5ba194e4..5a4d31a6 100644
--- a/db/__init__.py
+++ b/db/__init__.py
@@ -79,6 +79,7 @@ from .slugify import slugify
from .supports.create_support import create_support
from .supports.delete_support import delete_support
from .supports.list_supports import list_supports
+from .supports.support_dir import support_dir
from .supports.support_meta import support_meta
from .table_name_for import table_name_for
@@ -115,6 +116,7 @@ __all__ = [
"set_scorm_version",
"create_support",
"list_supports",
+ "support_dir",
"support_meta",
"delete_support",
"ONBOARDING_TYPES",
diff --git a/document_engine/__init__.py b/document_engine/__init__.py
index e4521c2d..59dc1b74 100644
--- a/document_engine/__init__.py
+++ b/document_engine/__init__.py
@@ -77,6 +77,7 @@ from .pages.list_document_pages import list_document_pages
from .pages.move_document_page import move_document_page
from .pages.update_document_page import update_document_page
from .rendering.render_document_element import render_document, render_document_element
+from .rendering.sanitize_svg_markup import sanitize_svg_markup
__all__ = [
"CARD_MODES",
@@ -121,6 +122,7 @@ __all__ = [
"sanitize_mots_config",
"sanitize_quiz_config",
"sanitize_scenario_config",
+ "sanitize_svg_markup",
"update_document_element_attributes",
"update_document_page",
]
diff --git a/document_engine/labels/element_kind_labels.py b/document_engine/labels/element_kind_labels.py
index 2b4db007..cd615ed2 100644
--- a/document_engine/labels/element_kind_labels.py
+++ b/document_engine/labels/element_kind_labels.py
@@ -11,7 +11,7 @@ from .mots_config import DEFAULT_MOTS_CONFIG
from .quiz_config import DEFAULT_QUIZ_CONFIG
from .scenario_config import DEFAULT_SCENARIO_CONFIG
-CONTENT_KINDS = ("titre", "paragraphe", "image", "bouton", "liste_puces", "liste_numerotee")
+CONTENT_KINDS = ("titre", "paragraphe", "image", "bouton", "liste_puces", "liste_numerotee", "badge", "carte")
MINIGAME_KINDS = ("quiz", "association", "memory", "mots", "scenario", "zones")
# "row" n'apparaît jamais dans la bibliothèque (créé implicitement par le
@@ -30,6 +30,8 @@ ELEMENT_KIND_LABELS: dict[str, str] = {
"bouton": "Bouton",
"liste_puces": "Liste à puces",
"liste_numerotee": "Liste numérotée",
+ "badge": "Étiquette",
+ "carte": "Carte",
"quiz": "Quiz",
"association": "Association",
"memory": "Memory",
@@ -56,15 +58,32 @@ def element_default_attributes(kind: str) -> dict[str, Any]:
if kind == "paragraphe":
return {"content": "Nouveau paragraphe de texte.", "style": "paragraphe", **_TEXT_DEFAULTS}
if kind == "image":
- return {"src": "", "alt": ""}
+ # svg_markup (optionnel) prend le pas sur src au rendu (voir
+ # render_document_element._render_image) — un contenu vectoriel
+ # dessiné/collé directement plutôt qu'un fichier hébergé.
+ return {"src": "", "alt": "", "svg_markup": ""}
if kind == "bouton":
- return {"label": "Bouton", "target": ""}
+ # attachment_stored_name/attachment_filename (optionnels) : un
+ # fichier téléchargeable joint au bouton (voir routes/document/
+ # document_element_upload_attachment.py), indépendant de `target`
+ # qui reste réservé à la navigation (URL/ancre). Jamais les deux
+ # à la fois côté UI (voir document-editor.js), mais rien ne
+ # l'empêche structurellement ici.
+ return {"label": "Bouton", "target": "", "attachment_stored_name": "", "attachment_filename": ""}
if kind in ("liste_puces", "liste_numerotee"):
# Une seule et même structure d'attributs pour les deux kinds —
# "ordonnée ou non" se lit directement sur le kind au moment du
# rendu (voir render_document_element._render_list), jamais un
# attribut "ordered" redondant à tenir synchronisé avec le kind.
return {"items": ["Premier élément", "Deuxième élément"]}
+ if kind == "badge":
+ return {"content": "Étiquette"}
+ if kind == "carte":
+ # Contenu pur (lettre/repère court + titre + description) — sans
+ # aucun choix de couleur/forme, laissé au futur système de
+ # templates (voir consigne du 24/09/2026 : moteur = contenu et
+ # mécanisme uniquement, jamais de style).
+ return {"label": "A", "title": "Titre de la carte", "description": "Description de la carte."}
if kind == "row":
return {"gap": 16, "align": "stretch", "justify": "flex-start"}
if kind == "quiz":
diff --git a/document_engine/labels/labels.md b/document_engine/labels/labels.md
index 2ccd7bbe..09400db8 100644
--- a/document_engine/labels/labels.md
+++ b/document_engine/labels/labels.md
@@ -6,12 +6,17 @@ attributs par défaut posés à la création de chaque type.
## `CONTENT_KINDS: tuple[str, ...]`
`("titre", "paragraphe", "image", "bouton", "liste_puces",
-"liste_numerotee")` — éléments du flux, peuvent être top-niveau ou
-enfants d'une rangée. `"liste_puces"`/`"liste_numerotee"` partagent la
-même structure d'attributs (`items`) ; c'est le `kind` lui-même qui
-décide `
` ou `` au rendu (voir
+"liste_numerotee", "badge", "carte")` — éléments du flux, peuvent être
+top-niveau ou enfants d'une rangée. `"liste_puces"`/`"liste_numerotee"`
+partagent la même structure d'attributs (`items`) ; c'est le `kind`
+lui-même qui décide `
` ou `` au rendu (voir
`document_engine/rendering/render_document_element.py::_render_list`),
-jamais un attribut `ordered` redondant.
+jamais un attribut `ordered` redondant. `"badge"` et `"carte"` sont du
+contenu PUR, sans aucun attribut de style/couleur/forme (décision du
+24/09/2026 : le moteur ne porte que contenu et mécanisme, tout habillage
+visuel revient à un futur système de templates) — voir aussi le mode SVG
+inline de `"image"` (`svg_markup`) et la pièce jointe téléchargeable de
+`"bouton"` (`attachment_filename`), ci-dessous.
## `MINIGAME_KINDS: tuple[str, ...]`
`("quiz", "association", "memory", "mots", "scenario", "zones")` —
@@ -35,12 +40,17 @@ 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` :
texte (`content/style` + `bold/italic/underline/align/color`),
- image (`src/alt`), bouton (`label/target`), liste à puces/numérotée
- (`items`, une liste de chaînes), rangée (`gap/align/justify`),
- quiz (`DEFAULT_QUIZ_CONFIG`, voir `quiz_config.py`), association
- (`DEFAULT_ASSOCIATION_CONFIG`, voir `association_config.py`), memory
- (`DEFAULT_MEMORY_CONFIG`, voir `memory_config.py`), autre mini-jeu
- (`theme_color`), ou `{}` pour un `kind` inconnu.
+ image (`src/alt/svg_markup` — `svg_markup` prend le pas sur `src` au
+ rendu, voir rendering.md), bouton (`label/target/
+ attachment_stored_name/attachment_filename` — la pièce jointe est
+ indépendante de `target`, réservé à la navigation), liste à
+ puces/numérotée (`items`, une liste de chaînes), badge (`content`),
+ carte (`label/title/description`, contenu pur sans couleur/forme),
+ rangée (`gap/align/justify`), quiz (`DEFAULT_QUIZ_CONFIG`, voir
+ `quiz_config.py`), association (`DEFAULT_ASSOCIATION_CONFIG`, voir
+ `association_config.py`), memory (`DEFAULT_MEMORY_CONFIG`, voir
+ `memory_config.py`), autre mini-jeu (`theme_color`), ou `{}` pour un
+ `kind` inconnu.
- **Exceptions** : aucune.
## `quiz_config.py` — modèle de données du mini-jeu Quiz
diff --git a/document_engine/rendering/render_document_element.py b/document_engine/rendering/render_document_element.py
index c21b7349..7f606516 100644
--- a/document_engine/rendering/render_document_element.py
+++ b/document_engine/rendering/render_document_element.py
@@ -64,6 +64,17 @@ def _render_text(el: dict[str, Any], _children_by_parent: dict[int | None, list[
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.
+ from .sanitize_svg_markup import sanitize_svg_markup
+
+ 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:
@@ -93,9 +104,37 @@ def _render_button(el: dict[str, Any], _children_by_parent: dict[int | None, lis
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 ""
+ # `data-attachment-filename` sert UNIQUEMENT de marqueur mécanique : un
+ # fichier a bien été joint (voir routes/document/
+ # document_element_upload_attachment.py). L'URL de téléchargement
+ # elle-même n'est jamais construite ici (ce renderer ne connaît pas le
+ # slug du support) — static/document/js/document-editor.js l'assemble
+ # à partir de `data-element-id` + FORGE_DOCUMENT.slug, même principe
+ # que le reste des appels AJAX de l'éditeur.
+ attachment_filename = html_lib.escape(str(a.get("attachment_filename", "")))
+ attachment_attr = f' data-attachment-filename="{attachment_filename}"' if attachment_filename else ""
return (
- f'"
+ f''
+ )
+
+
+def _render_badge(el: dict[str, Any], _children_by_parent: dict[int | None, list[dict[str, Any]]]) -> str:
+ content = html_lib.escape(str(el["attributes"].get("content", "")))
+ return f'
` stylé selon `style` (préréglage
taille/graisse/interligne) et `bold`/`italic`/`underline`/`align`/`color`.
-- **Image** : ``, ou un bloc placeholder si `src` est vide.
-- **Bouton** : `