Compare commits
59
Commits
9fb22be1ae
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9d7f560e3 | ||
|
|
4db1269348 | ||
|
|
8f879f6321 | ||
|
|
9ad50c58b8 | ||
|
|
6c7675fad0 | ||
|
|
e03bea39c5 | ||
|
|
1296edc2f8 | ||
|
|
746feb3796 | ||
|
|
be62275675 | ||
|
|
7730b3688f | ||
|
|
bd8f1d4b1e | ||
|
|
56afe77bd4 | ||
|
|
09449fe911 | ||
|
|
64ee7292d4 | ||
|
|
864ae697fd | ||
|
|
a34bcf4159 | ||
|
|
e7c6ed7159 | ||
|
|
eac45f1c9d | ||
|
|
b633126a7e | ||
|
|
fff098c60c | ||
|
|
71e6302503 | ||
|
|
7cb8986f58 | ||
|
|
6056a263da | ||
|
|
c6e173589f | ||
|
|
0e8c6efb06 | ||
|
|
cf349be030 | ||
|
|
7a0efe5c63 | ||
|
|
20ee8ef35b | ||
|
|
152d10fc33 | ||
|
|
fadddb7113 | ||
|
|
82223171a9 | ||
|
|
33d90e896e | ||
|
|
1a80cb32b5 | ||
|
|
60047a3658 | ||
|
|
2de22ff674 | ||
|
|
47c0f9e85c | ||
|
|
763d26c1f1 | ||
|
|
b8e7a4c672 | ||
|
|
b4e4e80b7c | ||
|
|
14ca9e32a8 | ||
|
|
e33b11458c | ||
|
|
a2b95bd547 | ||
|
|
8dc4b35dcf | ||
|
|
57c4de3d8a | ||
|
|
ecd483f352 | ||
|
|
ecea430ee2 | ||
|
|
0babad5018 | ||
|
|
d92f75a803 | ||
|
|
7e504b7865 | ||
|
|
a529857379 | ||
|
|
c04a81cfec | ||
|
|
b2292de122 | ||
|
|
d8be80ebd1 | ||
|
|
1b1307bb85 | ||
|
|
a34a95a3b4 | ||
|
|
4111081e1a | ||
|
|
cf46da3388 | ||
|
|
081b65f48f | ||
|
|
ce750ec697 |
+3
-2
@@ -129,7 +129,7 @@ SonarQube : voir section 2, sous-section "SonarQube" — CI restaurée (non-bloq
|
|||||||
| Site(s) | Outil / règle | Raison | Contexte |
|
| Site(s) | Outil / règle | Raison | Contexte |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `core/flask_app.py:22` | `python:S4502` (Sonar) | CSRF géré par `core/csrf_guard.py` — garde maison globale (`@app.before_request`), testée dans `test_csrf.py`, jamais Flask-WTF. Sonar ne reconnaît pas cette implémentation custom. | Phase 3 |
|
| `core/flask_app.py:22` | `python:S4502` (Sonar) | CSRF géré par `core/csrf_guard.py` — garde maison globale (`@app.before_request`), testée dans `test_csrf.py`, jamais Flask-WTF. Sonar ne reconnaît pas cette implémentation custom. | Phase 3 |
|
||||||
| `game_engine/data_actions/compute_operation.py` (×2), `static/game/js/play/offline/compute-operation.js` (×2), `static/game/js/scenes/collision-rules-editor.js` (×2), `static/game/js/triggers/trigger-editor.js` | `B311`/`S311`/`python:S2245`/`javascript:S2245` | Tirage aléatoire de jeu (dé, id local d'UI) — jamais un usage cryptographique. | Phase 3 |
|
| `game_engine/data_actions/compute_operation.py` (×2), `static/game/js/play/offline/compute-operation.js` (×2), `static/game/js/scenes/collision-rules-editor.js` (×2), `static/game/js/triggers/trigger-editor.js`, `document_engine/rendering/render_document_element.py` (×7 : `_render_association_player` ×2, `_render_memory_player` ×1, `_mots_place_word` ×3, `_mots_attempt_placement` ×1 — les 3 derniers ajoutés le 20/09/2026 pour le mini-jeu Mots mêlés) | `B311`/`S311`/`python:S2245`/`javascript:S2245` | Tirage aléatoire de jeu (dé, id local d'UI, mélange des deux colonnes du mini-jeu Association, mélange des cartes du mini-jeu Memory, direction/position de placement + lettre de remplissage de la grille du mini-jeu Mots mêlés) — jamais un usage cryptographique. | Phase 3 ; complété le 20/09/2026 |
|
||||||
| `static/game/js/play/offline/xapi-client.js` (18 sites) + `static/game/js/play/offline/__tests__/xapi-client.test.js` (2 sites) | `javascript:S5332` | Identifiants du vocabulaire xAPI standard ADL (`http://adlnet.gov/expapi/...`), jamais déréférencés en réseau — simples chaînes comparées/embarquées, le `http://` fait partie du texte fixé par la spec. Le vrai endpoint réseau (`config.endpoint`) est toujours saisi par le créateur, jamais un littéral de ce fichier. | Phase 3 |
|
| `static/game/js/play/offline/xapi-client.js` (18 sites) + `static/game/js/play/offline/__tests__/xapi-client.test.js` (2 sites) | `javascript:S5332` | Identifiants du vocabulaire xAPI standard ADL (`http://adlnet.gov/expapi/...`), jamais déréférencés en réseau — simples chaînes comparées/embarquées, le `http://` fait partie du texte fixé par la spec. Le vrai endpoint réseau (`config.endpoint`) est toujours saisi par le créateur, jamais un littéral de ce fichier. | Phase 3 |
|
||||||
| `publish/scorm_manifest.py` | `B406` (Bandit) | Seul fichier du dépôt qui touche du XML — uniquement en génération (`xml.sax.saxutils.escape`), jamais en parsing d'XML externe. | Phase 3 |
|
| `publish/scorm_manifest.py` | `B406` (Bandit) | Seul fichier du dépôt qui touche du XML — uniquement en génération (`xml.sax.saxutils.escape`), jamais en parsing d'XML externe. | Phase 3 |
|
||||||
| `scripts/game/build_demo_dialogues.py:61-63` | `python:S8371` (Sonar) | Accès direct `resp.headers["Location"]` volontaire : script d'usage unique jamais exécuté en production, un `KeyError` cru est un échec au moins aussi clair qu'un `.get()` renvoyant `None`. | Phase 3 |
|
| `scripts/game/build_demo_dialogues.py:61-63` | `python:S8371` (Sonar) | Accès direct `resp.headers["Location"]` volontaire : script d'usage unique jamais exécuté en production, un `KeyError` cru est un échec au moins aussi clair qu'un `.get()` renvoyant `None`. | Phase 3 |
|
||||||
@@ -141,7 +141,7 @@ SonarQube : voir section 2, sous-section "SonarQube" — CI restaurée (non-bloq
|
|||||||
| `static/game/js/scenes/scene-editor.js:214` (`CURRENT_SELECTED_ID = null`), `static/game/js/screen_edit/tree-panels.js:392` (`CURRENT_SELECTED_ID = selectedId \|\| null`) | `javascript:S2703` | Déclarée en **`var`** (pas `let`/`const`) dans `templates/game/scene_edit.html:833` (`var CURRENT_SELECTED_ID = {{ selected_id or 'null' }};`), chargé avant `tree-panels.js`/`scene-editor.js`/`trigger-editor.js`/`collision-rules-editor.js` (ordre vérifié, lignes 833/858/880/885/887/888) — un `var` de script classique attache directement à `window`, réassignable sans aucune restriction depuis n'importe quel autre `<script>` de la page (contrairement au cas `SCENE_OBJECT_NAMES` ci-dessous, qui lui était en `const`). Déjà documenté dans `.eslintrc.json` (`"CURRENT_SELECTED_ID": "writable"`). | Lot 2 "modernisation JS", 16/09/2026 |
|
| `static/game/js/scenes/scene-editor.js:214` (`CURRENT_SELECTED_ID = null`), `static/game/js/screen_edit/tree-panels.js:392` (`CURRENT_SELECTED_ID = selectedId \|\| null`) | `javascript:S2703` | Déclarée en **`var`** (pas `let`/`const`) dans `templates/game/scene_edit.html:833` (`var CURRENT_SELECTED_ID = {{ selected_id or 'null' }};`), chargé avant `tree-panels.js`/`scene-editor.js`/`trigger-editor.js`/`collision-rules-editor.js` (ordre vérifié, lignes 833/858/880/885/887/888) — un `var` de script classique attache directement à `window`, réassignable sans aucune restriction depuis n'importe quel autre `<script>` de la page (contrairement au cas `SCENE_OBJECT_NAMES` ci-dessous, qui lui était en `const`). Déjà documenté dans `.eslintrc.json` (`"CURRENT_SELECTED_ID": "writable"`). | Lot 2 "modernisation JS", 16/09/2026 |
|
||||||
| `static/game/js/scenes/collision-rules-editor.js:78` (`let _collisionWizard = null;`) et ses réassignations dans ce fichier, **et** `static/game/js/triggers/trigger-editor.js:787,937` (`triggerOpenAppendActionModal`/`screenTriggerOpenAppendActionModal`, `_collisionWizard = { bodyEl: body };` sans mot-clé) | `javascript:S2703` | Même pattern de partage inter-scripts que `SCENE_OBJECT_NAMES` : `trigger-editor.js` réutilise TELLES QUELLES les étapes de l'assistant de `collision-rules-editor.js` (`renderCollisionWizardChainStep`/`collisionWizardBuildLeafAction`, qui ne lisent que `.bodyEl` — voir commentaire ligne 784-786 de `trigger-editor.js`) pour poser "+ Ajouter une action" sur un déclencheur déjà existant, plutôt que de dupliquer ces étapes. `trigger-editor.js` est chargé AVANT `collision-rules-editor.js` (`templates/game/scene_edit.html:887-888`), mais sans risque de TDZ : les deux réassignations de `trigger-editor.js` sont à l'intérieur de fonctions déclenchées par un clic utilisateur, jamais exécutées avant que `collision-rules-editor.js` (et son `let _collisionWizard = null;`) n'ait fini de se charger. Déjà documenté dans `.eslintrc.json` (`"_collisionWizard": "writable"`). | Lot 2 "modernisation JS", 16/09/2026 ; complété lot 4, 18/09/2026 |
|
| `static/game/js/scenes/collision-rules-editor.js:78` (`let _collisionWizard = null;`) et ses réassignations dans ce fichier, **et** `static/game/js/triggers/trigger-editor.js:787,937` (`triggerOpenAppendActionModal`/`screenTriggerOpenAppendActionModal`, `_collisionWizard = { bodyEl: body };` sans mot-clé) | `javascript:S2703` | Même pattern de partage inter-scripts que `SCENE_OBJECT_NAMES` : `trigger-editor.js` réutilise TELLES QUELLES les étapes de l'assistant de `collision-rules-editor.js` (`renderCollisionWizardChainStep`/`collisionWizardBuildLeafAction`, qui ne lisent que `.bodyEl` — voir commentaire ligne 784-786 de `trigger-editor.js`) pour poser "+ Ajouter une action" sur un déclencheur déjà existant, plutôt que de dupliquer ces étapes. `trigger-editor.js` est chargé AVANT `collision-rules-editor.js` (`templates/game/scene_edit.html:887-888`), mais sans risque de TDZ : les deux réassignations de `trigger-editor.js` sont à l'intérieur de fonctions déclenchées par un clic utilisateur, jamais exécutées avant que `collision-rules-editor.js` (et son `let _collisionWizard = null;`) n'ait fini de se charger. Déjà documenté dans `.eslintrc.json` (`"_collisionWizard": "writable"`). | Lot 2 "modernisation JS", 16/09/2026 ; complété lot 4, 18/09/2026 |
|
||||||
| `static/game/js/triggers/trigger-editor.js:28` (`let SCENE_OBJECT_NAMES = ...`) | `javascript:S2703` | **Bug réel trouvé et corrigé** (pas un faux positif comme les 4 sites ci-dessus) : était déclarée en `const`, alors que `refreshSceneObjectNames()` (`static/game/js/scenes/scene-editor.js:754-759`) la réassigne après un fetch — deux `<script>` classiques sur la même page partagent un même environnement lexical global, mais une liaison `const` posée dans l'un ne peut pas être réassignée depuis l'autre (`TypeError: Assignment to constant variable.`, reproduit empiriquement via `node:vm`). Symptôme : renommer un personnage puis ouvrir un dialogue de déclencheur sans recharger la page ne montrait jamais le nouveau nom dans "qui parle". Corrigé en `let`, couvert par un test de non-régression (`static/game/js/scenes/__tests__/collision-rules-editor.test.js`, test `refreshSceneObjectNames`) qui échoue avec `TypeError` sur l'ancien code et passe avec le nouveau. | Lot 2 "modernisation JS", 16/09/2026 |
|
| `static/game/js/triggers/trigger-editor.js:28` (`let SCENE_OBJECT_NAMES = ...`) | `javascript:S2703` | **Bug réel trouvé et corrigé** (pas un faux positif comme les 4 sites ci-dessus) : était déclarée en `const`, alors que `refreshSceneObjectNames()` (`static/game/js/scenes/scene-editor.js:754-759`) la réassigne après un fetch — deux `<script>` classiques sur la même page partagent un même environnement lexical global, mais une liaison `const` posée dans l'un ne peut pas être réassignée depuis l'autre (`TypeError: Assignment to constant variable.`, reproduit empiriquement via `node:vm`). Symptôme : renommer un personnage puis ouvrir un dialogue de déclencheur sans recharger la page ne montrait jamais le nouveau nom dans "qui parle". Corrigé en `let`, couvert par un test de non-régression (`static/game/js/scenes/__tests__/collision-rules-editor.test.js`, test `refreshSceneObjectNames`) qui échoue avec `TypeError` sur l'ancien code et passe avec le nouveau. | Lot 2 "modernisation JS", 16/09/2026 |
|
||||||
| 32 sites `\|safe` (`templates/game/scene_edit.html`, `templates/game/play.html`, `templates/auth/register_2fa.html`, `templates/onboarding/onboarding_new.html`, `templates/game_dashboard_simple.html`, `templates/document/document_edit.html`) | `Web:S5247` (Sonar) | **Faux positif confirmé sur le fond, mais non-supprimable techniquement pour l'instant.** 3 sous-groupes : (1) `rendered_html`/`qr_svg`/`description` — HTML déjà échappé côté Python (`html.escape()`) ou généré sans texte libre utilisateur ; (2) 25 sites `*_json` — `db.json_for_script()` échappe déjà `</script>` (voir Phase 3) ; (3) `rendered_document` (`document_edit.html`, ajouté le 20/09/2026) — même sous-groupe (1) : produit par `document_engine.render_document`, qui échappe (`html.escape()`) tout contenu utilisateur avant interpolation (voir `document_engine/rendering/render_document_element.py`). Plusieurs syntaxes de suppression testées (commentaire Jinja `{# #}`, commentaire JS natif dans un `<script>`, bonne position de ligne) : **aucune ne fonctionne** avec l'analyseur Web de cette version de SonarQube. La résolution "Faux positif" via l'API est bloquée par le système de permissions de session. `sonar.issue.ignore.multicriteria` existe mais sans sélecteur de ligne (exclusion fichier entier uniquement) — écarté pour `scene_edit.html`/`play.html` (masquerait un futur `\|safe` réellement dangereux). Ces 32 sites restent visibles dans le rapport Sonar en l'état ; traités et compris, pas un point ouvert côté code. | Phase 3 + investigation du 16/09/2026 ; complété le 20/09/2026 |
|
| 31 sites `\|safe` (`templates/game/scene_edit.html`, `templates/game/play.html`, `templates/auth/register_2fa.html`, `templates/onboarding/onboarding_new.html`, `templates/game_dashboard_simple.html`, `templates/document/document_edit.html`) | `Web:S5247` (Sonar) | **Faux positif confirmé sur le fond, mais non-supprimable techniquement pour l'instant.** 3 sous-groupes : (1) `rendered_html`/`qr_svg`/`description` — HTML déjà échappé côté Python (`html.escape()`) ou généré sans texte libre utilisateur ; (2) 25 sites `*_json` — `db.json_for_script()` échappe déjà `</script>` (voir Phase 3) ; (3) `rendered_document` (`document_edit.html`) — même sous-groupe (1) : produit par `document_engine.render_document`, qui échappe (`html.escape()`) tout contenu utilisateur avant interpolation (voir `document_engine/rendering/render_document_element.py`). Plusieurs syntaxes de suppression testées (commentaire Jinja `{# #}`, commentaire JS natif dans un `<script>`, bonne position de ligne) : **aucune ne fonctionne** avec l'analyseur Web de cette version de SonarQube. La résolution "Faux positif" via l'API est bloquée par le système de permissions de session. `sonar.issue.ignore.multicriteria` existe mais sans sélecteur de ligne (exclusion fichier entier uniquement) — écarté pour `scene_edit.html`/`play.html` (masquerait un futur `\|safe` réellement dangereux). Ces sites restent visibles dans le rapport Sonar en l'état ; traités et compris, pas un point ouvert côté code. | Phase 3 + investigation du 16/09/2026 ; complété le 20/09/2026 et le 21/09/2026 |
|
||||||
| `static/game/js/play/offline/filter-repeater-rows.js:10,11`, `static/game/js/screen_edit/panel-init.js:254,261`, `static/game/js/play/offline/xapi-client.js:132` | `javascript:S8786` (ReDoS) | 3 regex distinctes (2 dupliquées dans 2 fichiers) testées empiriquement, aucune ne montre de backtracking super-linéaire réel — voir le détail complet juste en dessous du tableau (méthode reproductible). | Lot 1 "modernisation JS", 16/09/2026 |
|
| `static/game/js/play/offline/filter-repeater-rows.js:10,11`, `static/game/js/screen_edit/panel-init.js:254,261`, `static/game/js/play/offline/xapi-client.js:132` | `javascript:S8786` (ReDoS) | 3 regex distinctes (2 dupliquées dans 2 fichiers) testées empiriquement, aucune ne montre de backtracking super-linéaire réel — voir le détail complet juste en dessous du tableau (méthode reproductible). | Lot 1 "modernisation JS", 16/09/2026 |
|
||||||
| `static/game/js/play/dialogue-box-controller.js` (`forgeShowQuizBox`, ligne du `void widget.offsetWidth;`) | `javascript:S3735` | Force une lecture de mise en page (reflow) AVANT de reposer la classe `is-active`, pour que l'animation CSS d'entrée du quiz rejoue même si la boîte était déjà active juste avant (2 questions à la suite) — idiome JS standard, `void` marque explicitement une expression dont seul l'EFFET DE LECTURE compte, jamais la valeur. 3 formes essayées dans l'ordre, chacune rejetée par une règle Sonar différente : `void widget.offsetWidth;` (S3735, "retirer void") → `widget.offsetWidth;` seule (S905, "expression sans effet — accepté par ESLint ici, `no-unused-expressions` est désactivé dans ce projet, mais pas par Sonar") → `const _ = widget.offsetWidth;` (S1481, "variable jamais lue — accepté par ESLint via `varsIgnorePattern: ^_$`, pas par Sonar"). Aucune forme ne satisfait Sonar sans en recréer une autre : `void` restauré (la plus lisible/idiomatique des 3, et la seule aussi acceptée par ESLint) et documenté ici plutôt que de continuer à faire tourner ce carrousel. | Lot 4 "modernisation JS", 18/09/2026 |
|
| `static/game/js/play/dialogue-box-controller.js` (`forgeShowQuizBox`, ligne du `void widget.offsetWidth;`) | `javascript:S3735` | Force une lecture de mise en page (reflow) AVANT de reposer la classe `is-active`, pour que l'animation CSS d'entrée du quiz rejoue même si la boîte était déjà active juste avant (2 questions à la suite) — idiome JS standard, `void` marque explicitement une expression dont seul l'EFFET DE LECTURE compte, jamais la valeur. 3 formes essayées dans l'ordre, chacune rejetée par une règle Sonar différente : `void widget.offsetWidth;` (S3735, "retirer void") → `widget.offsetWidth;` seule (S905, "expression sans effet — accepté par ESLint ici, `no-unused-expressions` est désactivé dans ce projet, mais pas par Sonar") → `const _ = widget.offsetWidth;` (S1481, "variable jamais lue — accepté par ESLint via `varsIgnorePattern: ^_$`, pas par Sonar"). Aucune forme ne satisfait Sonar sans en recréer une autre : `void` restauré (la plus lisible/idiomatique des 3, et la seule aussi acceptée par ESLint) et documenté ici plutôt que de continuer à faire tourner ce carrousel. | Lot 4 "modernisation JS", 18/09/2026 |
|
||||||
| `static/game/js/screen_edit/tree-panels.js:32` (`restoreTreeCollapsedState`), `:57` (`saveFloatPanelState`), `:230` (sauvegarde état replié/déplié au clic) | `javascript:S2486` | Lecture/écriture `localStorage` purement cosmétique (éditeur seulement, jamais le jeu) : un échec (quota, storage désactivé) laisse au pire un panneau à sa position par défaut ou un nœud d'arborescence dans son état précédent — aucune donnée de jeu en jeu, aucun état perdu de façon irréversible. | Lot 3 "modernisation JS", 16/09/2026 |
|
| `static/game/js/screen_edit/tree-panels.js:32` (`restoreTreeCollapsedState`), `:57` (`saveFloatPanelState`), `:230` (sauvegarde état replié/déplié au clic) | `javascript:S2486` | Lecture/écriture `localStorage` purement cosmétique (éditeur seulement, jamais le jeu) : un échec (quota, storage désactivé) laisse au pire un panneau à sa position par défaut ou un nœud d'arborescence dans son état précédent — aucune donnée de jeu en jeu, aucun état perdu de façon irréversible. | Lot 3 "modernisation JS", 16/09/2026 |
|
||||||
@@ -152,6 +152,7 @@ SonarQube : voir section 2, sous-section "SonarQube" — CI restaurée (non-bloq
|
|||||||
| `static/game/js/play/offline/filter-repeater-rows.js:24` (`forgeDecodeClauses`) | `javascript:S2486` | **Corrigé, même patron que ci-dessus** : `_filtres_json` est un attribut rendu par le serveur, jamais tapé à la main — un JSON invalide y trahit presque toujours un bug côté éditeur/serveur. `console.warn('_filtres_json invalide, filtre ignoré', e)` ajouté, comportement inchangé (repli sur l'ancien format à 2 filtres fixes ou aucun filtre). Couvert par un nouveau test (`filter-repeater-rows.test.js`, `forgeDecodeClauses — _filtres_json invalide`). | Lot 4 "modernisation JS", 18/09/2026 |
|
| `static/game/js/play/offline/filter-repeater-rows.js:24` (`forgeDecodeClauses`) | `javascript:S2486` | **Corrigé, même patron que ci-dessus** : `_filtres_json` est un attribut rendu par le serveur, jamais tapé à la main — un JSON invalide y trahit presque toujours un bug côté éditeur/serveur. `console.warn('_filtres_json invalide, filtre ignoré', e)` ajouté, comportement inchangé (repli sur l'ancien format à 2 filtres fixes ou aucun filtre). Couvert par un nouveau test (`filter-repeater-rows.test.js`, `forgeDecodeClauses — _filtres_json invalide`). | Lot 4 "modernisation JS", 18/09/2026 |
|
||||||
| `static/game/js/play/offline/filter-repeater-rows.js:59`, `:70` (`forgeResolveVariablePath`, JSON.parse + navigation `.champ`/`[index]`) | `javascript:S2486` | **Documenté, pas corrigé — nature différente du cas ci-dessus** : ici `rawValue` est la VALEUR ACTUELLE d'une variable de jeu (modifiable librement par n'importe quelle action "Modifier une variable"), pas une config interne à l'éditeur — un chemin qui ne correspond pas à sa forme actuelle est un cas normal et attendu (ex. variable encore à sa valeur par défaut non-JSON), déjà explicitement documenté par le commentaire de la fonction ("Ne lève jamais... même convention que côté serveur"). Un `console.warn` ici bruiterait la console à chaque usage légitime. | Lot 4 "modernisation JS", 18/09/2026 |
|
| `static/game/js/play/offline/filter-repeater-rows.js:59`, `:70` (`forgeResolveVariablePath`, JSON.parse + navigation `.champ`/`[index]`) | `javascript:S2486` | **Documenté, pas corrigé — nature différente du cas ci-dessus** : ici `rawValue` est la VALEUR ACTUELLE d'une variable de jeu (modifiable librement par n'importe quelle action "Modifier une variable"), pas une config interne à l'éditeur — un chemin qui ne correspond pas à sa forme actuelle est un cas normal et attendu (ex. variable encore à sa valeur par défaut non-JSON), déjà explicitement documenté par le commentaire de la fonction ("Ne lève jamais... même convention que côté serveur"). Un `console.warn` ici bruiterait la console à chaque usage légitime. | Lot 4 "modernisation JS", 18/09/2026 |
|
||||||
| `static/document/js/document-editor.js` (`FORGE_DOC_STYLE_PRESETS`, `FORGE_DOC_SHAPE_KINDS`, `FORGE_DOC_SNAP_SIZE` — 3 sites) | `eslint:no-var`, `eslint:vars-on-top` | Constantes de premier niveau déclarées en `var` plutôt que `const` : un `<script src>` de page est rejoué TEL QUEL par `pjax.js` (`runScriptsIn`) à chaque navigation interne — une redéclaration `let`/`const` au premier niveau lèverait `SyntaxError: already declared` à la 2e exécution (voir l'en-tête de `static/pjax.js`, et le commentaire d'en-tête de ce fichier). `var` est le seul mot-clé sûr à ce niveau ; tout le reste du fichier (état mutable, y compris à l'intérieur des fonctions) est bien en `let`/`const`, porté par `window.forgeDocState` plutôt que par des variables de module (même convention que `static/game/js/scenes/scene-editor.js` et les autres scripts de page existants, qui n'ont eux aucune constante de ce genre à déclarer). | Session du 20/09/2026 |
|
| `static/document/js/document-editor.js` (`FORGE_DOC_STYLE_PRESETS`, `FORGE_DOC_SHAPE_KINDS`, `FORGE_DOC_SNAP_SIZE` — 3 sites) | `eslint:no-var`, `eslint:vars-on-top` | Constantes de premier niveau déclarées en `var` plutôt que `const` : un `<script src>` de page est rejoué TEL QUEL par `pjax.js` (`runScriptsIn`) à chaque navigation interne — une redéclaration `let`/`const` au premier niveau lèverait `SyntaxError: already declared` à la 2e exécution (voir l'en-tête de `static/pjax.js`, et le commentaire d'en-tête de ce fichier). `var` est le seul mot-clé sûr à ce niveau ; tout le reste du fichier (état mutable, y compris à l'intérieur des fonctions) est bien en `let`/`const`, porté par `window.forgeDocState` plutôt que par des variables de module (même convention que `static/game/js/scenes/scene-editor.js` et les autres scripts de page existants, qui n'ont eux aucune constante de ce genre à déclarer). | Session du 20/09/2026 |
|
||||||
|
| `templates/document/document_edit.html` (`.docPageRow`, rangées de l'onglet "Pages" du panneau gauche) + `static/document/js/document-editor.js` (`forgeDocRenderPageManagerList`) | `Web:S6819`, `Web:MouseEventWithoutKeyboardEquivalentCheck` (Sonar) | `div role="button" tabindex="0"` volontaire : chaque rangée contient de vrais `<button>` d'action (renommer/supprimer, voir `.docPageRowActions` — le réordonnancement, ancien 3ᵉ/4ᵉ bouton monter/descendre, est passé au glisser-déposer seul le 23/09/2026, retour utilisateur : "je pouvais changer l'ordre des pages en glisser déposer donc les flèches [...] sont inutiles"), qu'un `<button>` englobant ne pourrait pas contenir validement (imbrication de `<button>` invalide, le parseur HTML referme le bouton englobant trop tôt — même défaut structurel déjà rencontré et corrigé de la même façon ailleurs dans ce fichier). L'équivalent clavier (Entrée/Espace déclenche `forgeDocSwitchPage`, même effet que le clic) est posé côté JS (`row.addEventListener('keydown', ...)`), donc le finding clavier de Sonar est un faux positif : l'analyseur statique ne voit pas les `addEventListener` attachés dynamiquement. Vérifié par un test jsdom dédié (rôle `button`, équivalent clavier fonctionnel). | Session du 21/09/2026 ; renommé (panneau à onglets) le 23/09/2026 ; boutons monter/descendre retirés le 23/09/2026 |
|
||||||
|
|
||||||
### Détail — `javascript:S8786` (ReDoS), lot 1 "modernisation JS"
|
### Détail — `javascript:S8786` (ReDoS), lot 1 "modernisation JS"
|
||||||
|
|
||||||
|
|||||||
@@ -78,7 +78,11 @@ from .scoring.set_status import set_status
|
|||||||
from .slugify import slugify
|
from .slugify import slugify
|
||||||
from .supports.create_support import create_support
|
from .supports.create_support import create_support
|
||||||
from .supports.delete_support import delete_support
|
from .supports.delete_support import delete_support
|
||||||
|
from .supports.get_document_theme import get_document_theme
|
||||||
from .supports.list_supports import list_supports
|
from .supports.list_supports import list_supports
|
||||||
|
from .supports.remove_document_theme import remove_document_theme
|
||||||
|
from .supports.set_document_theme import set_document_theme
|
||||||
|
from .supports.support_dir import support_dir
|
||||||
from .supports.support_meta import support_meta
|
from .supports.support_meta import support_meta
|
||||||
from .table_name_for import table_name_for
|
from .table_name_for import table_name_for
|
||||||
|
|
||||||
@@ -115,8 +119,12 @@ __all__ = [
|
|||||||
"set_scorm_version",
|
"set_scorm_version",
|
||||||
"create_support",
|
"create_support",
|
||||||
"list_supports",
|
"list_supports",
|
||||||
|
"support_dir",
|
||||||
"support_meta",
|
"support_meta",
|
||||||
"delete_support",
|
"delete_support",
|
||||||
|
"get_document_theme",
|
||||||
|
"remove_document_theme",
|
||||||
|
"set_document_theme",
|
||||||
"ONBOARDING_TYPES",
|
"ONBOARDING_TYPES",
|
||||||
"DEFAULT_ONBOARDING_TYPE",
|
"DEFAULT_ONBOARDING_TYPE",
|
||||||
"get_onboarding_type",
|
"get_onboarding_type",
|
||||||
|
|||||||
@@ -6,7 +6,11 @@ indépendance."""
|
|||||||
|
|
||||||
from .create_support import create_support
|
from .create_support import create_support
|
||||||
from .delete_support import delete_support
|
from .delete_support import delete_support
|
||||||
|
from .ensure_document_pages_schema import ensure_document_pages_schema
|
||||||
|
from .get_document_theme import get_document_theme
|
||||||
from .list_supports import list_supports
|
from .list_supports import list_supports
|
||||||
|
from .remove_document_theme import remove_document_theme
|
||||||
|
from .set_document_theme import set_document_theme
|
||||||
from .support_connection import connect_support, install_support_teardown_safety_net
|
from .support_connection import connect_support, install_support_teardown_safety_net
|
||||||
from .support_dir import support_dir
|
from .support_dir import support_dir
|
||||||
from .support_meta import support_meta
|
from .support_meta import support_meta
|
||||||
@@ -18,8 +22,12 @@ __all__ = [
|
|||||||
"connect_support",
|
"connect_support",
|
||||||
"create_support",
|
"create_support",
|
||||||
"delete_support",
|
"delete_support",
|
||||||
|
"ensure_document_pages_schema",
|
||||||
|
"get_document_theme",
|
||||||
"install_support_teardown_safety_net",
|
"install_support_teardown_safety_net",
|
||||||
"list_supports",
|
"list_supports",
|
||||||
|
"remove_document_theme",
|
||||||
|
"set_document_theme",
|
||||||
"split_slug",
|
"split_slug",
|
||||||
"support_dir",
|
"support_dir",
|
||||||
"support_meta",
|
"support_meta",
|
||||||
|
|||||||
@@ -10,9 +10,17 @@ def create_support(name: str, owner_folder: str) -> str:
|
|||||||
"""Crée le dossier du support (projects/<owner_folder>/_supports/
|
"""Crée le dossier du support (projects/<owner_folder>/_supports/
|
||||||
<project_part>/) et sa base support.db dédiée — mirroir de
|
<project_part>/) et sa base support.db dédiée — mirroir de
|
||||||
db/games/create_game.py pour la mécanique de dossier/unicité de nom,
|
db/games/create_game.py pour la mécanique de dossier/unicité de nom,
|
||||||
mais un schéma entièrement différent (voir document_engine/ : un seul
|
mais un schéma entièrement différent (voir document_engine/ : un
|
||||||
document par support, pas d'écrans/objets de scène). owner_folder
|
support est composé de PAGES — _document_pages —, chacune portant son
|
||||||
n'est jamais optionnel ici (contrairement à create_game) : un support
|
propre flux d'éléments via _document_elements.page_id, jamais
|
||||||
|
d'écrans/objets de scène comme côté jeu). Créé ici SANS aucune page
|
||||||
|
(retour utilisateur : l'éditeur doit pouvoir s'ouvrir vide, "nouveau
|
||||||
|
projet par ex") — l'utilisateur clique "+ Ajouter une page" pour
|
||||||
|
commencer (voir document_engine/pages/add_document_page.py). Aucune
|
||||||
|
page n'est donc plus une garantie côté production ; seule la fixture
|
||||||
|
de test `support` (tests/conftest.py) en crée une par convénience pour
|
||||||
|
les tests qui ne portent pas sur ce cas précis. owner_folder n'est
|
||||||
|
jamais optionnel ici (contrairement à create_game) : un support
|
||||||
n'existe pas sans compte propriétaire."""
|
n'existe pas sans compte propriétaire."""
|
||||||
project_part = slugify(name)
|
project_part = slugify(name)
|
||||||
base = project_part
|
base = project_part
|
||||||
@@ -30,8 +38,17 @@ def create_support(name: str, owner_folder: str) -> str:
|
|||||||
"""
|
"""
|
||||||
CREATE TABLE _meta (key TEXT PRIMARY KEY, value TEXT);
|
CREATE TABLE _meta (key TEXT PRIMARY KEY, value TEXT);
|
||||||
|
|
||||||
|
CREATE TABLE _document_pages (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
title TEXT NOT NULL DEFAULT 'Page 1',
|
||||||
|
order_index INTEGER NOT NULL DEFAULT 0,
|
||||||
|
vertical_align TEXT NOT NULL DEFAULT 'top',
|
||||||
|
created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE _document_elements (
|
CREATE TABLE _document_elements (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
page_id INTEGER NOT NULL REFERENCES _document_pages(id) ON DELETE CASCADE,
|
||||||
parent_id INTEGER REFERENCES _document_elements(id) ON DELETE CASCADE,
|
parent_id INTEGER REFERENCES _document_elements(id) ON DELETE CASCADE,
|
||||||
kind TEXT NOT NULL,
|
kind TEXT NOT NULL,
|
||||||
order_index INTEGER NOT NULL DEFAULT 0,
|
order_index INTEGER NOT NULL DEFAULT 0,
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
from .support_connection import connect_support
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_document_pages_schema(slug: str) -> None:
|
||||||
|
"""Migration légère (même principe que db/global_vars/
|
||||||
|
ensure_global_vars_schema.py) : crée _document_pages si absente, et
|
||||||
|
ajoute page_id à _document_elements si absent — un support créé avant
|
||||||
|
le système de pages avait un seul document implicite ; ses éléments
|
||||||
|
existants deviennent tous les enfants d'une page "Page 1" créée ici
|
||||||
|
automatiquement (comportement le plus proche de l'ancien : un seul
|
||||||
|
document visible, qui devient simplement sa première page).
|
||||||
|
|
||||||
|
Ne recrée PLUS "Page 1" à chaque appel dès que _document_pages est
|
||||||
|
vide (bug qui empêchait tout support d'atteindre 0 page — un support
|
||||||
|
sans aucune page est un état valide depuis le retour utilisateur
|
||||||
|
"l'éditeur doit pouvoir s'ouvrir sans aucune page"). La création
|
||||||
|
automatique de "Page 1" ne se déclenche plus qu'une seule fois, au
|
||||||
|
moment précis de cette migration historique (juste avant d'ajouter la
|
||||||
|
colonne page_id, voir plus bas) — jamais ensuite."""
|
||||||
|
conn = connect_support(slug)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS _document_pages (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
title TEXT NOT NULL DEFAULT 'Page 1',
|
||||||
|
order_index INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
# vertical_align : alignement vertical du contenu de la page
|
||||||
|
# (top/center/bottom — voir document_engine/pages/
|
||||||
|
# set_document_page_vertical_align.py), ajouté après la création
|
||||||
|
# initiale des pages — même pragmatisme que page_id ci-dessous, une
|
||||||
|
# valeur par défaut CONSTANTE plutôt qu'une contrainte CHECK.
|
||||||
|
page_cols = {r["name"] for r in conn.execute("PRAGMA table_info(_document_pages)").fetchall()}
|
||||||
|
if "vertical_align" not in page_cols:
|
||||||
|
conn.execute("ALTER TABLE _document_pages ADD COLUMN vertical_align TEXT NOT NULL DEFAULT 'top'")
|
||||||
|
|
||||||
|
cols = {r["name"] for r in conn.execute("PRAGMA table_info(_document_elements)").fetchall()}
|
||||||
|
if "page_id" not in cols:
|
||||||
|
# Vrai support pré-pages : ses éléments existants (s'il y en a)
|
||||||
|
# doivent atterrir quelque part — jamais recréé une fois cette
|
||||||
|
# migration ponctuelle passée (page_id existera alors déjà).
|
||||||
|
page_row = conn.execute("SELECT id FROM _document_pages ORDER BY order_index LIMIT 1").fetchone()
|
||||||
|
if page_row is None:
|
||||||
|
conn.execute("INSERT INTO _document_pages (title, order_index) VALUES ('Page 1', 0)")
|
||||||
|
page_row = conn.execute("SELECT id FROM _document_pages ORDER BY order_index LIMIT 1").fetchone()
|
||||||
|
first_page_id = page_row["id"]
|
||||||
|
# SQLite autorise ADD COLUMN avec une valeur par défaut CONSTANTE
|
||||||
|
# (jamais une contrainte REFERENCES ici, même pragmatisme que
|
||||||
|
# db/rows/ensure_player_id_column.py : la contrainte de clé
|
||||||
|
# étrangère n'existe que pour les supports créés APRÈS ce
|
||||||
|
# changement, via create_support.py).
|
||||||
|
conn.execute(f"ALTER TABLE _document_elements ADD COLUMN page_id INTEGER NOT NULL DEFAULT {first_page_id}")
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
from .support_connection import connect_support
|
||||||
|
|
||||||
|
|
||||||
|
def get_document_theme(slug: str) -> str | None:
|
||||||
|
"""None si aucun thème n'a jamais été appliqué à ce support (état par
|
||||||
|
défaut : contenu non stylé, voir document_engine/rendering/) — jamais
|
||||||
|
une valeur par défaut arbitraire ici, `routes/document/document_edit.py`
|
||||||
|
décide seul quoi faire de ce None (ne charger aucune feuille de style
|
||||||
|
de thème)."""
|
||||||
|
conn = connect_support(slug)
|
||||||
|
row = conn.execute("SELECT value FROM _meta WHERE key = 'theme'").fetchone()
|
||||||
|
conn.close()
|
||||||
|
return row["value"] if row else None
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
from .support_connection import connect_support
|
||||||
|
|
||||||
|
|
||||||
|
def remove_document_theme(slug: str) -> None:
|
||||||
|
"""Retire le thème appliqué (retour utilisateur du 26/09/2026 :
|
||||||
|
"aucun modèle" dans la modale doit "revenir à un document de base")
|
||||||
|
— supprime la LIGNE `_meta` plutôt que d'y stocker une valeur vide,
|
||||||
|
pour que get_document_theme continue de renvoyer `None` (son
|
||||||
|
contrat documenté : "aucun thème n'a jamais été appliqué"), jamais
|
||||||
|
une chaîne vide qui violerait silencieusement ce contrat pour tout
|
||||||
|
appelant qui compare à `None`."""
|
||||||
|
conn = connect_support(slug)
|
||||||
|
conn.execute("DELETE FROM _meta WHERE key = 'theme'")
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
from .support_connection import connect_support
|
||||||
|
|
||||||
|
|
||||||
|
def set_document_theme(slug: str, theme_id: str) -> None:
|
||||||
|
"""Mirroir de db/games/game_type_catalog.py::set_onboarding_type
|
||||||
|
(même pattern INSERT OR REPLACE sur _meta) — `theme_id` n'est PAS
|
||||||
|
revalidé contre le catalogue ici (couche données pure) : c'est
|
||||||
|
routes/document/document_theme_apply.py, seul appelant, qui vérifie
|
||||||
|
que le thème existe avant d'appeler cette fonction."""
|
||||||
|
conn = connect_support(slug)
|
||||||
|
conn.execute("INSERT OR REPLACE INTO _meta (key, value) VALUES ('theme', ?)", (theme_id,))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from .get_document_theme import get_document_theme
|
||||||
from .support_connection import connect_support
|
from .support_connection import connect_support
|
||||||
|
|
||||||
|
|
||||||
@@ -10,4 +11,5 @@ def support_meta(slug: str) -> dict[str, Any]:
|
|||||||
return {
|
return {
|
||||||
"slug": slug,
|
"slug": slug,
|
||||||
"name": row["value"] if row else slug,
|
"name": row["value"] if row else slug,
|
||||||
|
"theme": get_document_theme(slug),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,545 @@
|
|||||||
|
# Audit des options de mise en forme — état des lieux et suivi
|
||||||
|
|
||||||
|
Document de travail (retour utilisateur du 26/09/2026) : liste tout ce
|
||||||
|
qui manque en CSS, élément par élément, et sert de suivi pendant
|
||||||
|
l'implémentation (un élément à la fois, dans l'ordre du tableau —
|
||||||
|
implémentation → test utilisateur → validation → audit final → commit →
|
||||||
|
élément suivant).
|
||||||
|
|
||||||
|
## Méthode de travail (rappel)
|
||||||
|
|
||||||
|
1. Un élément du tableau à la fois, dans l'ordre.
|
||||||
|
2. J'implémente tout ce qui manque pour cet élément.
|
||||||
|
3. Je préviens l'utilisateur, qui teste et valide/invalide.
|
||||||
|
4. Dernier audit sur l'élément pour vérifier que rien n'est oublié.
|
||||||
|
5. Si tout est ok : commit, puis élément suivant.
|
||||||
|
|
||||||
|
Règle transversale (retour utilisateur) : **jamais de champ de texte
|
||||||
|
libre** pour une valeur CSS — uniquement des boutons, des curseurs, des
|
||||||
|
listes déroulantes, des sélecteurs de couleur natifs. Les valeurs
|
||||||
|
numériques optionnelles utilisent un curseur + une case "activer"
|
||||||
|
(un `<input type="range">` ne peut pas représenter "aucune valeur").
|
||||||
|
|
||||||
|
## Tableau d'audit (état au 26/09/2026, avant implémentation)
|
||||||
|
|
||||||
|
| Élément | Mise en forme possible aujourd'hui | Ce qui manque (audit CSS complet) |
|
||||||
|
|---|---|---|
|
||||||
|
| **Titre / Paragraphe** (`titre`/`paragraphe`) | Gras, italique, souligné, couleur du texte, alignement horizontal du texte, largeur maximale | **Typo** : barré, surligné (overline), police de caractère, taille de police (actuellement figée par le style titre1/titre2/paragraphe/légende), espacement des lettres/mots, hauteur de ligne, transformation (majuscules/minuscules/capitales), indentation de la 1ʳᵉ ligne, ombre portée du texte, retour à la ligne (`white-space`), troncature avec "…" (`text-overflow`), sens d'écriture (RTL)<br>**Boîte** : padding, margin (y compris négatif/auto pour centrer), hauteur, min/max-height, min-width<br>**Bordure/ombre** : style (pointillé/tireté/double…), épaisseur, couleur, par côté, arrondi par coin, ombre portée du bloc (`box-shadow`)<br>**Fond** : couleur de fond, dégradé, image de fond, opacité du bloc<br>**Position/affichage** : position du bloc dans son conteneur (pas juste le texte dedans), `overflow`, `z-index`<br>**Effets** : transition/animation au survol, curseur, filtre (flou, contraste…)<br>**Responsive** : aucune valeur ne peut différer entre Bureau/Tablette/Mobile |
|
||||||
|
| **Image** (`image`) | URL, texte alternatif, SVG inline | **Dimensionnement** : largeur/hauteur explicites, min/max, ratio (`aspect-ratio`), `object-fit` (cover/contain/fill), `object-position`<br>**Bordure/ombre** : style/couleur/épaisseur/par côté, arrondi par coin (actuellement fixe à 10px, non réglable), ombre portée<br>**Effets** : filtre CSS (niveaux de gris, sépia, luminosité, flou), `mix-blend-mode`, découpe (`clip-path`, ex. cercle/hexagone), overlay couleur/dégradé au survol<br>**Comportement** : lien cliquable, ouverture en plein écran/lightbox au clic, chargement différé (`loading=lazy`), légende (caption) affichée sous l'image<br>**Position** : alignement horizontal dans son conteneur, `margin`, `padding` autour<br>**Responsive** : image différente ou recadrage différent par taille d'écran |
|
||||||
|
| **Bouton** (`bouton`) | Texte, cible (URL/ancre), fichier joint | **Typo** : police, taille, gras/italique, transformation (majuscules), espacement des lettres<br>**Boîte** : padding, margin, largeur/hauteur, alignement du bloc<br>**Bordure/ombre** : style/couleur/épaisseur/par côté, arrondi par coin, ombre portée<br>**Fond** : couleur, dégradé, image<br>**États interactifs** : styles distincts survol/actif/désactivé (aucune notion d'état n'existe)<br>**Icône** : aucune icône à côté du texte du bouton (contrairement au badge)<br>**Effets** : transition au survol, curseur |
|
||||||
|
| **Liste à puces / numérotée** (`liste_puces`/`liste_numerotee`) | Contenu des éléments uniquement | **Typo** : tout (police, taille, gras/italique/souligné, couleur, interligne)<br>**Puces/numéros** : style de puce (`list-style-type`), image de puce personnalisée, position (intérieure/extérieure), couleur/taille des puces indépendante du texte<br>**Boîte** : padding/margin globaux ET par élément de liste, espacement entre éléments réglable, indentation<br>**Bordure/fond** : par élément de liste ou sur la liste entière<br>**Listes imbriquées** : aucune notion de sous-liste |
|
||||||
|
| **Étiquette** (`badge`) | Icône SVG, largeur, arrondi (uniforme), gras, majuscules | **Typo** : italique, souligné, barré, police, taille de police, couleur du texte, espacement des lettres<br>**Boîte** : hauteur, padding, margin, min/max-width<br>**Bordure** : style (actuellement toujours plein), couleur, épaisseur, **par côté** (demandé explicitement), arrondi **par coin** (actuellement un seul rayon pour les 4 coins)<br>**Fond** : couleur (actuellement figée par le thème), dégradé<br>**Ombre** : `box-shadow`<br>**Position de l'icône** : avant/après le texte, taille de l'icône réglable |
|
||||||
|
| **Carte** (`carte`) | Contenu uniquement (repère/titre/description) | Tout : typo, boîte, bordure, fond, ombre, dimensionnement — rien n'est réglable |
|
||||||
|
| **Rangée** (`row`, conteneur flex) | `gap`, `justify-content`, `align-items` | **Boîte** : padding, margin, largeur/hauteur explicites avec poignées de redimensionnement<br>**Bordure/fond/ombre** : rien<br>**Flex avancé** : `flex-wrap` (retour à la ligne), `flex-direction` (actuellement toujours en ligne, jamais en colonne), `align-content`, ordre des enfants, `flex-grow`/`flex-basis` par enfant (chacun a la même part aujourd'hui)<br>**Position** : `overflow`, `position` (sticky/absolute), `z-index`<br>**Responsive** : passage automatique en colonne sous un seuil de largeur |
|
||||||
|
| **Mini-jeux** (`quiz`/`association`/`memory`/`mots`/`scenario`/`zones`) | `theme_color` (accent) + contenu propre à chaque jeu | Tout le reste : bordure, fond, dimensions, police, espacement, ombre — entièrement figés par le CSS du thème, aucun réglage par instance |
|
||||||
|
|
||||||
|
**Catégories transversales oubliées, valables pour tous les éléments** :
|
||||||
|
`opacity` (transparence du bloc), `cursor`, transitions/animations CSS,
|
||||||
|
styles conditionnels par état (survol/focus/actif/désactivé), et le
|
||||||
|
**responsive** (aucun réglage ne peut varier entre Bureau/Tablette/Mobile
|
||||||
|
alors que ce sélecteur existe déjà dans l'éditeur).
|
||||||
|
|
||||||
|
## Suivi par élément
|
||||||
|
|
||||||
|
### 1. Titre / Paragraphe — ✅ audité et validé (commité)
|
||||||
|
|
||||||
|
Implémenté : gras/italique/souligné/**barré**, majuscules/minuscules/
|
||||||
|
capitales, police (liste déroulante de polices web-safe), taille de
|
||||||
|
police, hauteur de ligne, espacement des lettres, ombre du texte (3
|
||||||
|
préréglages), couleur du texte (sélecteur natif), largeur maximale,
|
||||||
|
padding, margin, couleur de fond (+ bouton "Transparent"), arrondi des
|
||||||
|
bords, hauteur/hauteur min/hauteur max/largeur min, ombre portée du
|
||||||
|
bloc (3 préréglages), opacité, position du bloc (gauche/centré/droite/
|
||||||
|
pleine largeur), bordure par côté (style/épaisseur/couleur,
|
||||||
|
indépendants sur les 4 côtés).
|
||||||
|
|
||||||
|
Mécanisme partagé créé pour l'occasion : `document_engine/rendering/
|
||||||
|
box_style.py` (`render_box_style`, `default_border`, `BOX_DEFAULTS`) —
|
||||||
|
réutilisé tel quel par tous les éléments suivants du tableau.
|
||||||
|
|
||||||
|
Volontairement laissé de côté (rarement utile pour du contenu de
|
||||||
|
formation, à ajouter si besoin) : surlignage (overline), indentation de
|
||||||
|
1ʳᵉ ligne, `white-space`/troncature "…", sens d'écriture RTL, dégradé/
|
||||||
|
image de fond, `overflow`/`z-index`/position absolue, transitions au
|
||||||
|
survol, réglages différents par taille d'écran (responsive), arrondi
|
||||||
|
par coin (un seul rayon pour les 4 coins, comme l'étiquette).
|
||||||
|
|
||||||
|
**Changement rétroactif (au moment de l'Image, ci-dessous)** : `width`
|
||||||
|
(largeur fixe) a été ajouté à `box_style.py` partagé — Titre/Paragraphe
|
||||||
|
gagne donc aussi ce réglage a posteriori (en plus de la largeur MAXIMALE
|
||||||
|
déjà là), sans repasser par une validation dédiée puisque c'est un ajout
|
||||||
|
pur (aucun comportement existant modifié).
|
||||||
|
|
||||||
|
Bugs transversaux trouvés et corrigés pendant ce chantier (concernent
|
||||||
|
TOUT l'éditeur, pas seulement Titre/Paragraphe) :
|
||||||
|
- Panneau Propriétés jamais reconstruit après un clic (boutons/segments
|
||||||
|
ne reflétaient leur nouvel état qu'après rechargement de la page) —
|
||||||
|
corrigé dans `forgeDocUpdateAttributes`, point d'entrée unique de
|
||||||
|
toute mise à jour d'attribut.
|
||||||
|
- Champs `.docField input` (padding/bordure/fond/largeur 100%) hérités
|
||||||
|
à tort par les cases à cocher et curseurs, cassant leur apparence
|
||||||
|
native.
|
||||||
|
|
||||||
|
### 2. Image — ✅ audité et validé (commité)
|
||||||
|
|
||||||
|
Implémenté : légende (`caption`, enveloppe dans `<figure>/<figcaption>`),
|
||||||
|
ajustement dans son cadre (`object_fit` : taille réelle/couvrir/
|
||||||
|
contenir/étirer), format/ratio (`aspect_ratio` : libre/carré/4:3/16:9),
|
||||||
|
filtre (3 préréglages : noir et blanc/sépia/flouté), chargement différé
|
||||||
|
(`loading="lazy"`), comportement au clic mutuellement exclusif (aucun /
|
||||||
|
lien externe dans un nouvel onglet / plein écran — un vrai overlay
|
||||||
|
plein écran côté client, `forgeDocOpenImageLightbox`), tous les
|
||||||
|
attributs de boîte partagés (dont la nouvelle **largeur fixe**, ajoutée
|
||||||
|
à `box_style.py` à cette occasion).
|
||||||
|
|
||||||
|
Comme pour les mini-jeux et la pièce jointe d'un bouton, le lien et le
|
||||||
|
plein écran ne sont réellement cliquables qu'en Mode Aperçu — en
|
||||||
|
édition, le clic sélectionne l'élément (la navigation native du lien
|
||||||
|
est bloquée pour ne pas quitter l'éditeur par accident).
|
||||||
|
|
||||||
|
**Ajout (retour utilisateur : "il manque la possibilité d'uploader une
|
||||||
|
image")** : téléversement d'un fichier depuis l'ordinateur (PNG/JPG/
|
||||||
|
GIF/WEBP/SVG), en plus du champ URL externe déjà là — mirroir exact du
|
||||||
|
mécanisme déjà en place pour la pièce jointe d'un bouton :
|
||||||
|
`routes/document/document_element_upload_image.py` (stocke sous
|
||||||
|
`uploads/`, jamais `attachments/` — pas de `as_attachment`, l'image doit
|
||||||
|
s'afficher, pas se télécharger) et `routes/document/
|
||||||
|
document_uploaded_file.py` (route de service dédiée). Le fichier
|
||||||
|
téléversé vide `svg_markup` au passage (les deux modes ne coexistent
|
||||||
|
jamais). Le panneau propose maintenant les deux : sélecteur de fichier
|
||||||
|
(natif, pas de champ texte) en premier, champ URL externe en second
|
||||||
|
pour une image déjà hébergée ailleurs.
|
||||||
|
|
||||||
|
Volontairement laissé de côté (complexité/valeur douteuse pour du
|
||||||
|
contenu de formation) : `mix-blend-mode`, découpe `clip-path`, overlay
|
||||||
|
au survol, arrondi par coin, réglages responsive par taille d'écran.
|
||||||
|
|
||||||
|
Bug pré-existant corrigé au passage : `docImageSrc`/`docImageAlt`
|
||||||
|
n'étaient pas échappés avant insertion dans l'attribut `value` du
|
||||||
|
panneau Propriétés (mineur, mais corrigé puisque cette fonction était
|
||||||
|
déjà réécrite).
|
||||||
|
|
||||||
|
**Bug réel corrigé (retour utilisateur : "la position de bloc ne
|
||||||
|
fonctionne pas sur l'image")** : `render_box_style` (padding/margin/
|
||||||
|
fond/bordure/largeur/position du bloc, dont `align_self`) était
|
||||||
|
appliqué à l'`<img>`/`<div>` INTERNE, jamais à son enveloppe
|
||||||
|
(`<figure>`/`<a class="docImageLink">`/`<div class=
|
||||||
|
"docImageLightboxTrigger">`) quand une légende ou un comportement au
|
||||||
|
clic en ajoutait une — `align-self` posé sur un simple descendant du
|
||||||
|
flex-item n'a aucun effet CSS, d'où le bouton "position du bloc" sans
|
||||||
|
effet visible dès qu'une légende ou un lien était configuré. Corrigé
|
||||||
|
dans `_render_image` (`document_engine/rendering/
|
||||||
|
render_document_element.py`) : le style de bloc cible désormais
|
||||||
|
toujours l'élément réellement top-niveau (enfant direct de
|
||||||
|
`.docPageContent`), quel que soit l'emboîtement. Changement de
|
||||||
|
comportement réel sur du contenu existant : une image avec légende/lien
|
||||||
|
et un fond/une bordure/un padding déjà réglés les verra désormais
|
||||||
|
appliqués à TOUT le bloc (image + légende), pas seulement à l'image —
|
||||||
|
c'est le comportement correct/attendu, mais je le signale car ça change
|
||||||
|
le rendu visuel d'éléments déjà créés.
|
||||||
|
|
||||||
|
**Bug réel corrigé (retour utilisateur : "quand j'ajoute une image ça
|
||||||
|
crée des pages à l'infini")** : une photo importée à sa taille native
|
||||||
|
pouvait dépasser une page entière à elle seule ; la pagination
|
||||||
|
automatique (`forgeDocCheckPageOverflow`, static/document/js/
|
||||||
|
document-editor.js) la déplaçait alors sans fin vers une page neuve,
|
||||||
|
qui débordait identiquement. Corrigé une première fois par un garde-fou générique (si tout le
|
||||||
|
contenu de la page déborde déjà, rien à répartir, on arrête) + une
|
||||||
|
hauteur maximale par défaut sur `img.docImage` (plafonnée à la hauteur
|
||||||
|
intérieure de la page, `static/document/document-editor.css`).
|
||||||
|
|
||||||
|
**Affiné ensuite (retour utilisateur : "si l'image uploadée est trop
|
||||||
|
grande je préfère qu'elle soit redimensionnée plutôt que bouger sur une
|
||||||
|
autre page")** : `forgeDocCheckPageOverflow` (static/document/js/
|
||||||
|
document-editor.js) évite de paginer une image qui déborde quand il
|
||||||
|
reste assez de place pour un résultat encore utilisable — elle est
|
||||||
|
rétrécie SUR PLACE (`max-height` posé en style inline,
|
||||||
|
calculé à partir de l'espace réellement disponible sous elle sur SA
|
||||||
|
page actuelle, légende comprise) à chaque rafraîchissement du canevas.
|
||||||
|
Purement visuel, jamais persisté en attribut (l'espace disponible
|
||||||
|
dépend du contenu au-dessus, qui change en éditant). Un réglage
|
||||||
|
explicite de hauteur maximale via le panneau reste prioritaire (une
|
||||||
|
valeur inline posée par un attribut serait recalculée par-dessus à
|
||||||
|
l'affichage suivant si elle déborde encore). Le garde-fou anti-boucle
|
||||||
|
et le défaut CSS restent en place pour les AUTRES kinds et comme filet
|
||||||
|
de sécurité au tout premier rendu (avant que le JS n'ait tourné).
|
||||||
|
|
||||||
|
**Changement de conception (retour utilisateur : "ce cadre ne devrait
|
||||||
|
pas changer de taille en fonction de la taille de l'image mais être
|
||||||
|
fixe et contraindre l'image dedans")** : plutôt que de rétrécir
|
||||||
|
dynamiquement une image trop grande selon l'espace disponible (approche
|
||||||
|
fragile, source des deux bugs ci-dessus), un nouvel élément image a
|
||||||
|
maintenant un cadre FIXE dès sa création — `object_fit="cover"` +
|
||||||
|
`height="220px"` par défaut au lieu de vides (`document_engine/labels/
|
||||||
|
element_kind_labels.py`) — une photo importée est donc TOUJOURS rognée
|
||||||
|
pour remplir ce cadre, quelle que soit sa résolution native. "Taille
|
||||||
|
réelle" reste un choix explicite possible dans le panneau (segmented
|
||||||
|
"Ajustement dans son cadre"). **Ne s'applique qu'aux NOUVEAUX éléments
|
||||||
|
image** — un élément déjà créé avant ce changement garde ses attributs
|
||||||
|
`object_fit`/`height` existants (jamais re-migré automatiquement,
|
||||||
|
`sanitize_element_attributes` ne touche pas au kind "image", voir sa
|
||||||
|
docstring) ; pour en faire bénéficier une image déjà présente, régler
|
||||||
|
manuellement "Couvrir" + une hauteur via le panneau. Le rétrécissement
|
||||||
|
dynamique (garde-fou anti-boucle + `forgeDocCheckPageOverflow` appelé
|
||||||
|
après upload/mise à jour d'attribut) reste en place comme filet de
|
||||||
|
sécurité pour une hauteur explicite déraisonnablement grande.
|
||||||
|
|
||||||
|
**Bug réel corrigé une seconde fois (retour utilisateur : "ce n'est pas
|
||||||
|
redimensionner")** : le rétrécissement ci-dessus vit dans
|
||||||
|
`forgeDocCheckPageOverflow`, appelée uniquement par
|
||||||
|
`forgeDocRefreshCanvas` (ajout/déplacement/suppression/Annuler-
|
||||||
|
Rétablir) — mais **téléverser un fichier dans une image existante**
|
||||||
|
(`forgeDocApiUploadImage`) et **changer un attribut quelconque**
|
||||||
|
(`forgeDocUpdateAttributes`, le point d'entrée central de tous les
|
||||||
|
panneaux) patchent chacun le DOM directement, SANS jamais passer par
|
||||||
|
ce chemin : le rétrécissement ne se déclenchait donc jamais après un
|
||||||
|
upload. Corrigé en appelant explicitement `forgeDocCheckPageOverflow()`
|
||||||
|
à la fin de ces deux fonctions. Pas de test automatisé possible ici :
|
||||||
|
aucune suite de tests n'existe pour `document-editor.js`
|
||||||
|
(`package.json` ne couvre que `static/game/js/`), vérification
|
||||||
|
manuelle uniquement.
|
||||||
|
|
||||||
|
**Affiné une troisième fois (retour utilisateur : "l'ajout de page
|
||||||
|
quand le contenu déborde dois rester et même être plus intelligent, la
|
||||||
|
nouvelle page dois etre sous la page qui deborde meme si ya d'autre
|
||||||
|
page")** : la pagination automatique (pour tout kind, pas seulement les
|
||||||
|
images désormais épargnées ci-dessus) insérait toujours la page neuve
|
||||||
|
en toute fin de la bande d'onglets (`add_document_page` l'ajoute
|
||||||
|
toujours à la fin), même si d'autres pages existaient déjà après celle
|
||||||
|
qui déborde — déborder sur la page 2 d'un support qui en compte 5
|
||||||
|
ajoutait la nouvelle page en position 6 au lieu de 3. Corrigé dans
|
||||||
|
`forgeDocCheckPageOverflow` : la page est créée puis immédiatement
|
||||||
|
déplacée (`forgeDocApiPageMove`, mécanisme déjà existant pour le
|
||||||
|
glisser-déposer du panneau Pages) juste après la page active, décalant
|
||||||
|
les pages suivantes d'un cran. Extraction d'un helper partagé
|
||||||
|
`forgeDocReorderLocalPage` (état client après un déplacement) réutilisé
|
||||||
|
par le glisser-déposer ET par ce nouveau cas, pour ne pas dupliquer ce
|
||||||
|
calcul. Vérification côté serveur déjà couverte par les tests existants
|
||||||
|
de `move_document_page`/`document_page_move` (l'insertion "au milieu"
|
||||||
|
de la bande d'onglets y est déjà testée) ; le déclenchement côté client
|
||||||
|
reste manuel faute de suite de tests JS.
|
||||||
|
|
||||||
|
**Affiné une quatrième fois** : sous un seuil de place restante
|
||||||
|
(`FORGE_DOC_MIN_IMAGE_HEIGHT`, 60px) il n'y a plus de place RÉELLE sur
|
||||||
|
la page (pas seulement pour cette image) — l'image bascule alors dans
|
||||||
|
la pagination normale ci-dessus au lieu d'être rétrécie à une taille
|
||||||
|
inutilisable, ce qui empêchait sinon une page pleine d'images
|
||||||
|
d'enchaîner sur une nouvelle page. Vérifié par un test de bout en bout
|
||||||
|
en conditions réelles (navigateur automatisé Playwright contre le
|
||||||
|
serveur local, sur un support jetable créé puis supprimé pour
|
||||||
|
l'occasion) : 5 images vides ajoutées à la suite produisent bien 3
|
||||||
|
pages, chacune avec le cadre fixe "Couvrir" actif par défaut.
|
||||||
|
|
||||||
|
**Audit final** : chaque ligne du tableau d'audit initial pour
|
||||||
|
l'élément Image est couverte — implémentée (dimensionnement/ratio/
|
||||||
|
object-fit, bordure+ombre via `box_style.py`, filtre, lien/lightbox/
|
||||||
|
lazy-load/légende, position du bloc dont l'alignement, upload de
|
||||||
|
fichier) ou explicitement différée ci-dessus avec sa raison
|
||||||
|
(`object-position`, `mix-blend-mode`, `clip-path`, overlay au survol,
|
||||||
|
arrondi par coin, responsive par taille d'écran — mêmes exclusions que
|
||||||
|
Titre/Paragraphe, valeur douteuse pour du contenu de formation). Rien
|
||||||
|
d'oublié constaté à cette relecture. Validé par l'utilisateur, prêt à
|
||||||
|
committer.
|
||||||
|
|
||||||
|
### 3. Bouton — ✅ audité et validé (commité)
|
||||||
|
|
||||||
|
Implémenté : gras/italique, majuscules/minuscules/capitales, police
|
||||||
|
(liste déroulante web-safe, réutilise `FORGE_DOC_FONT_FAMILY_OPTIONS`
|
||||||
|
déjà créé pour Titre/Paragraphe), taille de police, espacement des
|
||||||
|
lettres, couleur du texte (sélecteur natif), icône SVG optionnelle
|
||||||
|
(position avant/après le texte, taille réglable — le bouton dépasse ici
|
||||||
|
l'étiquette, qui n'a toujours qu'une icône fixe sans position ni taille
|
||||||
|
réglables), tous les attributs de boîte partagés (padding, margin,
|
||||||
|
couleur de fond, arrondi, largeur/hauteur, ombre portée, opacité,
|
||||||
|
position du bloc, bordure par côté). États interactifs survol/actif :
|
||||||
|
effet visuel UNIVERSEL (assombrissement léger au survol, léger
|
||||||
|
tassement au clic), automatique pour tous les boutons sans réglage à
|
||||||
|
faire — jamais une couleur de survol personnalisable par bouton
|
||||||
|
(mécanisme CSS fragile pour une valeur ajoutée faible en contenu de
|
||||||
|
formation). Curseur (`cursor:pointer`) et transition au survol déjà en
|
||||||
|
place.
|
||||||
|
|
||||||
|
Mécanisme partagé réutilisé tel quel : `document_engine/rendering/
|
||||||
|
box_style.py` (comme Titre/Paragraphe/Image) + `sanitize_svg_markup`
|
||||||
|
(comme l'Image et l'Étiquette pour l'icône). Extraction d'une constante
|
||||||
|
JS partagée `FORGE_DOC_TEXT_TRANSFORM_OPTIONS` (utilisée par Titre/
|
||||||
|
Paragraphe ET Bouton, plus de duplication de ce tableau d'options).
|
||||||
|
|
||||||
|
Volontairement laissé de côté (même rationale que Titre/Paragraphe/
|
||||||
|
Image) : dégradé/image de fond, arrondi par coin (un seul rayon pour
|
||||||
|
les 4 coins), réglages responsive par taille d'écran. **État
|
||||||
|
"désactivé"** délibérément absent : un bouton de contenu de formation
|
||||||
|
n'est pas un vrai contrôle de formulaire avec un état programmatique
|
||||||
|
désactivé — aucun équivalent clair sans inventer un concept artificiel;
|
||||||
|
à ajouter si un besoin précis se présente.
|
||||||
|
|
||||||
|
**Audit final** : chaque ligne du tableau d'audit initial pour
|
||||||
|
l'élément Bouton est couverte — typo (police/taille/gras/italique/
|
||||||
|
transformation/espacement), boîte (padding/margin/largeur/hauteur/
|
||||||
|
position du bloc), bordure/ombre (style/couleur/épaisseur/par côté +
|
||||||
|
le nouveau réglage "les 4 côtés à la fois"/ombre portée), fond
|
||||||
|
(couleur), icône, effets (transition/curseur) — chacune implémentée ou
|
||||||
|
explicitement différée ci-dessus avec sa raison (dégradé/image de fond,
|
||||||
|
arrondi par coin, responsive, état désactivé). 9 tests dédiés passent
|
||||||
|
(224 au total dans `tests/document/`), ruff/mypy --strict/bandit/
|
||||||
|
vulture/import-linter/eslint/stylelint tous clean. Rien d'oublié
|
||||||
|
constaté à cette relecture. Validé par l'utilisateur, prêt à committer.
|
||||||
|
|
||||||
|
**Ajout transversal pendant le test (retour utilisateur : "pour les
|
||||||
|
bordures, il faudrait une option pour modifier les 4 bordures en même
|
||||||
|
temps")** : une rangée "Bordure — les 4 côtés à la fois" a été ajoutée
|
||||||
|
au-dessus du réglage par côté existant, dans le module PARTAGÉ
|
||||||
|
`forgeDocRenderBoxFieldsHtml`/`forgeDocBindBoxFields` (static/document/
|
||||||
|
js/document-editor.js) — un changement de style/épaisseur/couleur y
|
||||||
|
applique la MÊME valeur aux 4 côtés d'un coup (en plus, jamais à la
|
||||||
|
place, du réglage par côté qui reste utilisable après pour affiner).
|
||||||
|
Repart de l'état actuel si les 4 côtés portent déjà la même valeur,
|
||||||
|
sinon d'un état neutre. Bénéficie automatiquement à TOUS les éléments
|
||||||
|
qui utilisent déjà ce module (Titre/Paragraphe/Image/Bouton), aucune
|
||||||
|
modification nécessaire ailleurs.
|
||||||
|
|
||||||
|
### 4. Liste à puces / numérotée — ✅ audité et validé (commité)
|
||||||
|
|
||||||
|
**Portée actée avec l'utilisateur avant implémentation** (question
|
||||||
|
posée explicitement, deux catégories du tableau impliquaient de
|
||||||
|
transformer `items` — une simple liste de chaînes — en objets
|
||||||
|
structurés) : style sur la LISTE ENTIÈRE uniquement, jamais par élément
|
||||||
|
individuel ni de sous-listes imbriquées (chantier bien plus lourd,
|
||||||
|
différé à une demande séparée si besoin). Seule exception actée : un
|
||||||
|
padding UNIFORME appliqué à chaque élément (`item_padding`), pas encore
|
||||||
|
réglable individuellement.
|
||||||
|
|
||||||
|
Implémenté : gras/italique/souligné, police (liste déroulante web-safe,
|
||||||
|
`FORGE_DOC_FONT_FAMILY_OPTIONS` réutilisé), taille de police, hauteur de
|
||||||
|
ligne, couleur du texte, style de puce (`list_style_type`, options
|
||||||
|
propres à chaque kind — disque/cercle/carré/aucune pour puces,
|
||||||
|
1-2-3/01-02-03/i-ii-iii/I-II-III/a-b-c/A-B-C/aucune pour numérotée),
|
||||||
|
position de la puce (intérieure/extérieure), couleur et taille de puce
|
||||||
|
indépendantes du texte, puce personnalisée (icône SVG, liste à puces
|
||||||
|
uniquement — encodée en URI de données pour `list-style-image`),
|
||||||
|
espacement intérieur par élément (`item_padding`, **"6px" par défaut,
|
||||||
|
retour utilisateur explicite : "il faut un padding de base par élément
|
||||||
|
de liste car y en a pas aujourd'hui"** — n'existait pas du tout avant),
|
||||||
|
espacement entre éléments réglable (`item_spacing`), tous les attributs
|
||||||
|
de boîte partagés sur la liste entière (padding/margin/fond/bordure/
|
||||||
|
ombre/opacité/position du bloc).
|
||||||
|
|
||||||
|
Mécanisme technique notable : `marker_color`/`marker_size`/
|
||||||
|
`item_padding`/`item_spacing` ne peuvent pas passer par un style inline
|
||||||
|
classique sur le `<ul>`/`<ol>` (impossible de cibler le `::marker` ou le
|
||||||
|
padding des `<li>` enfants depuis le style de leur parent) — résolu via
|
||||||
|
des propriétés personnalisées CSS (`--doc-marker-color` etc.), posées
|
||||||
|
en inline sur le conteneur et consommées par une règle CSS dédiée
|
||||||
|
(`.docList li`/`.docList li::marker`) qui en hérite. Même technique déjà
|
||||||
|
utilisée pour la position du bloc des éléments enveloppés (Image).
|
||||||
|
|
||||||
|
Volontairement laissé de côté (portée actée ci-dessus) : style/bordure/
|
||||||
|
fond par élément individuel, sous-listes imbriquées — nécessiteraient
|
||||||
|
de transformer `items` (liste de chaînes) en objets structurés, refonte
|
||||||
|
du panneau et du stockage. Comme les autres éléments : responsive par
|
||||||
|
taille d'écran.
|
||||||
|
|
||||||
|
**Existant retroactivement inchangé** : une liste déjà créée avant ce
|
||||||
|
commit garde ses anciens attributs (juste `items`) — `item_padding`
|
||||||
|
n'apparaît en style inline QUE pour les nouvelles listes ; ouvrir le
|
||||||
|
panneau Propriétés d'une ancienne liste et toucher un réglage la fait
|
||||||
|
bénéficier des nouveaux défauts au passage.
|
||||||
|
|
||||||
|
**Bug réel corrigé pendant le test (retour utilisateur : "si j'enlève
|
||||||
|
les puces ou que les puces se mettent à l'intérieur, il reste un
|
||||||
|
espace devant la liste, cet espace doit être supprimé")** : le
|
||||||
|
`padding-left:1.4em` par défaut (réservé pour une puce EXTÉRIEURE)
|
||||||
|
n'a plus lieu d'être dès que `list_style_type="none"` ou
|
||||||
|
`list_style_position="inside"` — `_render_list` ajoute alors
|
||||||
|
automatiquement `padding-left:0;`, prioritaire sur le CSS mais toujours
|
||||||
|
cédant la place à un `padding` uniforme réglé explicitement par
|
||||||
|
ailleurs (attributs de boîte partagés).
|
||||||
|
|
||||||
|
**Deux ajouts transversaux pendant le test, bénéficiant à plusieurs
|
||||||
|
éléments à la fois :**
|
||||||
|
|
||||||
|
1. **Sections "Contenu"/"Conteneur" dans le panneau Propriétés (retour
|
||||||
|
utilisateur : "il faut distinguer par des sections la propriété qui
|
||||||
|
touche au conteneur de celles qui touchent à l'élément qu'il
|
||||||
|
contient sinon c'est pas compréhensible")** : un sous-titre visuel
|
||||||
|
(bordure du dessus) sépare maintenant, dans CHAQUE panneau qui
|
||||||
|
utilise `box_style.py` (Titre/Paragraphe, Image, Bouton, Liste), les
|
||||||
|
champs propres au CONTENU (texte, icône, puces...) de ceux qui
|
||||||
|
touchent au CONTENEUR (padding/margin/fond/bordure/ombre/opacité/
|
||||||
|
position du bloc/alignement du contenu — voir point 2). Le sous-titre
|
||||||
|
"Conteneur" est posé UNE SEULE FOIS, à l'intérieur de
|
||||||
|
`forgeDocRenderBoxFieldsHtml` (le module déjà partagé), jamais dupliqué
|
||||||
|
à chaque appelant.
|
||||||
|
2. **Alignement vertical du CONTENU dans son bloc (retour utilisateur :
|
||||||
|
"je peux augmenter la hauteur d'un conteneur mais pas l'alignement
|
||||||
|
vertical à l'intérieur, pour les listes et il faut aussi vérifier
|
||||||
|
pour les autres")** : nouvel attribut partagé `content_align`
|
||||||
|
(`"top"` par défaut, `"center"`/`"bottom"`) posé dans `BOX_DEFAULTS`,
|
||||||
|
rendu via `render_content_align(a)` — volontairement PAS fusionné
|
||||||
|
dans `render_box_style` (l'alignement du contenu dépend de l'axe
|
||||||
|
interne du conteneur : `justify-content` convient à un conteneur en
|
||||||
|
COLONNE — Titre/Paragraphe/Liste, rendus `display:flex;
|
||||||
|
flex-direction:column;` pour l'occasion, et la figure d'une image
|
||||||
|
légendée, déjà flex-colonne — mais serait FAUX pour le Bouton, une
|
||||||
|
RANGÉE icône+texte qui gère déjà son axe vertical via `align-items`,
|
||||||
|
déjà correct sans réglage). Contrôle exclu explicitement du panneau
|
||||||
|
Bouton (`includeContentAlign: false`) pour ne jamais afficher un
|
||||||
|
réglage sans effet.
|
||||||
|
|
||||||
|
**Bug réel corrigé, puis CORRIGÉ UNE SECONDE FOIS après un retour de
|
||||||
|
régression (capture à l'appui, thème "Sécurité incendie")** :
|
||||||
|
|
||||||
|
1. *Premier symptôme* ("dans les liste numéroté [...] la position des
|
||||||
|
élément à l'intérieur de base est verticale au lieu d'être
|
||||||
|
horizontale") : ce thème remplace la puce native d'une liste par un
|
||||||
|
badge (`::before`, voir `static/document/themes/
|
||||||
|
securite-incendie.css`), pensé pour s'afficher À CÔTÉ du texte.
|
||||||
|
Corrigé une première fois en posant `display:flex` sur `.docList li`
|
||||||
|
(`static/document/document-editor.css`).
|
||||||
|
2. *Régression introduite par ce premier correctif* ("quand j'enlève le
|
||||||
|
thème [...] on voit plus les puces ou les numéros") : `display:flex`
|
||||||
|
posé DIRECTEMENT sur le `<li>` remplace entièrement son
|
||||||
|
`display:list-item` natif — ça supprime le marqueur natif (puce/
|
||||||
|
numéro) pour TOUTE liste, avec ou sans thème (l'affirmation inverse
|
||||||
|
dans le premier correctif était FAUSSE, corrigée après une
|
||||||
|
vérification en conditions réelles, navigateur automatisé). **Corrigé
|
||||||
|
en ciblant le `::before` LUI-MÊME** (`display:inline-block;
|
||||||
|
vertical-align:middle; margin-right:10px;`), jamais son parent : le
|
||||||
|
`<li>` garde son `display:list-item` natif (donc son marqueur), et le
|
||||||
|
badge s'aligne quand même à côté du texte qui le suit dans le flux
|
||||||
|
normal.
|
||||||
|
3. *Redondance restante* : une fois le marqueur natif restauré, les
|
||||||
|
listes du thème affichaient À LA FOIS leur badge personnalisé ET le
|
||||||
|
marqueur natif (disc/decimal) en double — le thème ne les avait
|
||||||
|
jamais explicitement désactivés (aucun `list-style:none` dans
|
||||||
|
`securite-incendie.css`, il comptait implicitement sur le marqueur
|
||||||
|
natif pour disparaître tout seul). Corrigé en ajoutant ce reset —
|
||||||
|
avec le sélecteur d'élément (`ul.docList[...]`/`ol.docList[...]`),
|
||||||
|
jamais seulement les classes/attributs : `static/style.css` (site
|
||||||
|
large) porte une règle `.content ol:not([type]) {
|
||||||
|
list-style-type: decimal; }` d'une spécificité légèrement supérieure
|
||||||
|
qui l'emportait sinon silencieusement.
|
||||||
|
4. *Dernière régression du même correctif* : le badge numéroté du thème
|
||||||
|
posait lui-même `display:flex` (pour centrer son chiffre) — une
|
||||||
|
valeur qui BLOCKIFIE le `::before` (le repousse hors du flux en
|
||||||
|
ligne, au-dessus du texte), défaisant le point 1 pour ce cas précis.
|
||||||
|
Corrigé en `display:inline-flex` (garde le badge en ligne tout en
|
||||||
|
centrant quand même le chiffre à l'intérieur).
|
||||||
|
|
||||||
|
Chaque étape vérifiée par un navigateur automatisé (Playwright) contre
|
||||||
|
le serveur local, sur un support jetable créé puis supprimé pour
|
||||||
|
l'occasion — thème appliqué et retiré successivement, capture d'écran
|
||||||
|
et styles calculés (`getComputedStyle`) inspectés à chaque fois, pas
|
||||||
|
seulement supposés corrects.
|
||||||
|
|
||||||
|
**Audit final** : chaque ligne du tableau d'audit initial pour l'élément
|
||||||
|
Liste est couverte —
|
||||||
|
**Typo** (police/taille/gras/italique/souligné/couleur/interligne) ✅,
|
||||||
|
**Puces/numéros** (style de puce validé par kind, image de puce
|
||||||
|
personnalisée pour les puces, position intérieure/extérieure, couleur/
|
||||||
|
taille de puce indépendantes du texte via propriétés personnalisées
|
||||||
|
CSS) ✅,
|
||||||
|
**Boîte** (padding/margin sur la liste entière via `box_style.py`,
|
||||||
|
padding UNIFORME par élément — `item_padding`, avec un vrai défaut non
|
||||||
|
vide comme demandé —, espacement entre éléments réglable —
|
||||||
|
`item_spacing` —, indentation couverte par le padding partagé) ✅,
|
||||||
|
**Bordure/fond** sur la liste entière ✅.
|
||||||
|
Volontairement différés, portée actée explicitement AVANT
|
||||||
|
implémentation avec l'utilisateur (question posée, réponse : liste
|
||||||
|
entière seulement) : bordure/fond/padding/margin **par élément
|
||||||
|
individuel** (au-delà du padding uniforme) et **listes imbriquées** —
|
||||||
|
transformeraient `items` (liste de chaînes) en objets structurés, un
|
||||||
|
chantier bien plus lourd que le reste du tableau, à traiter séparément
|
||||||
|
si demandé. Comme les autres éléments : responsive par taille d'écran.
|
||||||
|
245 tests passent, ruff/mypy --strict/bandit/vulture/import-linter/
|
||||||
|
eslint/stylelint tous clean. Rien d'oublié constaté à cette relecture.
|
||||||
|
Validé par l'utilisateur (y compris les 4 bugs de thème trouvés et
|
||||||
|
corrigés pendant la validation), prêt à committer.
|
||||||
|
|
||||||
|
### 5. Étiquette (badge) — à compléter
|
||||||
|
|
||||||
|
Déjà fait (session précédente) : icône SVG, largeur, arrondi uniforme,
|
||||||
|
gras, majuscules. Restant selon le tableau ci-dessus : italique,
|
||||||
|
souligné, barré, police, taille de police, couleur du texte, espacement
|
||||||
|
des lettres, hauteur, padding, margin, bordure par côté, ombre portée,
|
||||||
|
position de l'icône. Pourra réutiliser directement `box_style.py`.
|
||||||
|
|
||||||
|
### 6. Carte — à faire
|
||||||
|
|
||||||
|
### 7. Rangée (row) — à faire
|
||||||
|
|
||||||
|
### 8. Mini-jeux — à faire
|
||||||
|
|
||||||
|
## Décisions transversales actées pendant ce chantier
|
||||||
|
|
||||||
|
- Un seul rayon d'arrondi pour les 4 coins (pas par coin) — cohérent
|
||||||
|
avec l'étiquette déjà en place, à revoir si demandé explicitement.
|
||||||
|
- Padding/margin : une valeur UNIFORME sur les 4 côtés (curseur unique),
|
||||||
|
pas par côté — la bordure, elle, reste par côté (demande explicite).
|
||||||
|
- Ombres (texte et bloc) : 3 préréglages (Aucune/Légère/Marquée) plutôt
|
||||||
|
que des curseurs séparés offset/flou/couleur — reste simple à
|
||||||
|
utiliser, ajustable si besoin de plus de finesse plus tard.
|
||||||
|
- Polices : liste fermée de polices web-safe (aucun chargement dynamique
|
||||||
|
de Google Fonts depuis l'éditeur) — évite d'introduire un mécanisme de
|
||||||
|
chargement de police, hors périmètre de cet audit.
|
||||||
|
|
||||||
|
## Fonctionnalité hors tableau : glisser-déposer un élément vers une autre page
|
||||||
|
|
||||||
|
Retour utilisateur du 26/09/2026 : "j'aimerais pouvoir glisser déposer
|
||||||
|
un élément d'une page dans une autre page" — sans rapport avec l'audit
|
||||||
|
de mise en forme élément par élément, mais traité dans la foulée.
|
||||||
|
Le mécanisme bas niveau (`document_engine.move_document_element_to_page`
|
||||||
|
+ la route `/elements/<id>/move-to-page`) existait déjà, utilisé
|
||||||
|
uniquement par la pagination automatique (voir
|
||||||
|
`forgeDocCheckPageOverflow`) — déjà bien testé côté serveur
|
||||||
|
(`test_document_elements.py`/`test_document_routes.py`).
|
||||||
|
|
||||||
|
Ajout : glisser un élément du canevas jusqu'à une rangée du panneau
|
||||||
|
Pages (onglet "Pages" du panneau gauche) le déplace vers cette page —
|
||||||
|
même charge utile de glisser (`"text/forge-doc-move"`) que le
|
||||||
|
réordonnancement au sein d'une page, aucune modification de la source
|
||||||
|
de glisser côté canevas. Nouvelle fonction `forgeDocMoveElementToPage`
|
||||||
|
(commande Annuler/Rétablir complète, comme le reste des mutations).
|
||||||
|
Surlignage visuel de la rangée survolée (`.docPageRow--dropTarget`),
|
||||||
|
distinct de `.is-active`/`.is-dragging`. Pas de test automatisé
|
||||||
|
possible côté client (aucune suite de tests n'existe pour
|
||||||
|
`document-editor.js`), vérification manuelle uniquement — le mécanisme
|
||||||
|
serveur sous-jacent, lui, reste couvert par les tests existants.
|
||||||
|
|
||||||
|
## Fonctionnalité hors tableau : retirer le thème appliqué
|
||||||
|
|
||||||
|
Retour utilisateur du 26/09/2026 : après avoir choisi un thème puis
|
||||||
|
vidé toutes les pages, le thème restait appliqué — question légitime
|
||||||
|
("c'est voulu ?"), réponse : OUI pour la séparation contenu/thème (déjà
|
||||||
|
le cas), mais il manquait un moyen de retirer un thème une fois choisi.
|
||||||
|
Ajout demandé : "dans la modale de choix des modèles, ajoute-en un qui
|
||||||
|
s'appelle Aucun modèle, si l'utilisateur le choisit ça enlève tout
|
||||||
|
modèle de style choisi pour revenir à un document de base".
|
||||||
|
|
||||||
|
- **Nouvelle fonction bas niveau** `db.remove_document_theme(slug)`
|
||||||
|
(`db/supports/remove_document_theme.py`) — supprime la LIGNE `_meta`
|
||||||
|
plutôt que d'y stocker une chaîne vide, pour que `get_document_theme`
|
||||||
|
continue de renvoyer `None` (son contrat documenté : "aucun thème
|
||||||
|
n'a jamais été appliqué"), jamais une chaîne vide qui le violerait
|
||||||
|
silencieusement pour tout appelant qui compare à `None` (dont le test
|
||||||
|
déjà existant `test_new_support_has_no_theme_by_default`).
|
||||||
|
- **Route** `/document/<slug>/theme/apply` : `theme_id` vide retire
|
||||||
|
désormais le thème et s'arrête là — `mode` n'a alors aucun sens
|
||||||
|
(aucun contenu de démonstration pour "aucun modèle") et est ignoré,
|
||||||
|
jamais validé ni utilisé dans ce cas.
|
||||||
|
- **Modale "Utiliser un modèle"** : nouvelle carte "Aucun modèle"
|
||||||
|
toujours en tête de liste (même catalogue vide), id sentinelle `""`
|
||||||
|
— distincte de `null` (qui reste réservé à "rien n'a encore été
|
||||||
|
cliqué dans la modale", `forgeDocSelectedTemplateId` à l'ouverture).
|
||||||
|
La sélectionner remplace l'aperçu (rien à prévisualiser, "aucun
|
||||||
|
modèle" n'a pas de contenu de démonstration) par un message explicite
|
||||||
|
et un unique bouton "Retirer le modèle" (jamais les deux boutons
|
||||||
|
"contenu actuel/du modèle", qui supposent un vrai thème choisi) —
|
||||||
|
confirmation native avant l'action (changement visuel notable, même
|
||||||
|
si le contenu n'est jamais touché).
|
||||||
|
- Testé : `db/supports/remove_document_theme.py` (2 tests bas niveau,
|
||||||
|
`tests/document/test_support_lifecycle.py`) + la route (2 tests,
|
||||||
|
`tests/document/test_document_routes.py` — retire vraiment le thème,
|
||||||
|
laisse le contenu intact, ignore `mode`). Le déclenchement côté
|
||||||
|
client (clic sur la carte/le bouton) reste manuel faute de suite de
|
||||||
|
tests JS, comme le reste de l'éditeur.
|
||||||
+81
-12
@@ -1,16 +1,17 @@
|
|||||||
"""
|
"""
|
||||||
document_engine — support de formation : entité racine séparée du jeu 2D
|
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
|
(voir docs/plan/PLAN.md). Un support est composé de plusieurs PAGES (voir
|
||||||
couches sur le même canevas :
|
document_engine/pages/, retour utilisateur du 21/09/2026 : "il faut
|
||||||
- le flux de contenu (titre/paragraphe/image/bouton/mini-jeux), organisé
|
implémenter un système de page") ; chaque page structure son propre
|
||||||
en rangées ("row") par le moteur d'inférence de layout (voir
|
contenu en deux couches sur le même canevas :
|
||||||
document_engine/rendering/render_document_element.py) ;
|
le flux de contenu (titre/paragraphe/image/bouton/mini-jeux), organisé en
|
||||||
- la couche de formes libres (rectangle/cercle/triangle/trait), position-
|
rangées ("row") par le moteur d'inférence de layout (voir
|
||||||
nées en absolu (x/y/width/height/rotation/z_index).
|
document_engine/rendering/render_document_element.py).
|
||||||
|
|
||||||
Tout est stocké dans support.db (voir db/supports/) : une seule table,
|
Tout est stocké dans support.db (voir db/supports/) : _document_pages (une
|
||||||
_document_elements, portant les deux couches via parent_id (NULL = top-
|
ligne par page) et _document_elements (chaque élément appartient à
|
||||||
niveau ou forme libre, sinon = enfant d'une rangée).
|
exactement une page via page_id, avec parent_id NULL au top-niveau ou
|
||||||
|
enfant d'une rangée).
|
||||||
|
|
||||||
Aucun import croisé avec game_engine ou tout module lié au jeu 2D — voir
|
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
|
le contrat import-linter dans pyproject.toml (game_engine | document_engine
|
||||||
@@ -26,15 +27,34 @@ from .elements.delete_document_element import delete_document_element
|
|||||||
from .elements.get_document_element import get_document_element
|
from .elements.get_document_element import get_document_element
|
||||||
from .elements.list_document_elements import list_document_elements
|
from .elements.list_document_elements import list_document_elements
|
||||||
from .elements.move_document_element import move_document_element
|
from .elements.move_document_element import move_document_element
|
||||||
|
from .elements.move_document_element_to_page import move_document_element_to_page
|
||||||
from .elements.update_document_element_attributes import update_document_element_attributes
|
from .elements.update_document_element_attributes import update_document_element_attributes
|
||||||
|
from .labels.association_config import (
|
||||||
|
DEFAULT_ASSOCIATION_CONFIG,
|
||||||
|
MAX_PAIRS,
|
||||||
|
MIN_PAIRS,
|
||||||
|
sanitize_association_config,
|
||||||
|
)
|
||||||
from .labels.element_kind_labels import (
|
from .labels.element_kind_labels import (
|
||||||
CONTENT_KINDS,
|
CONTENT_KINDS,
|
||||||
ELEMENT_KIND_LABELS,
|
ELEMENT_KIND_LABELS,
|
||||||
ELEMENT_LIBRARY,
|
ELEMENT_LIBRARY,
|
||||||
MINIGAME_KINDS,
|
MINIGAME_KINDS,
|
||||||
SHAPE_KINDS,
|
|
||||||
element_default_attributes,
|
element_default_attributes,
|
||||||
)
|
)
|
||||||
|
from .labels.memory_config import (
|
||||||
|
CARD_MODES,
|
||||||
|
DEFAULT_MEMORY_CONFIG,
|
||||||
|
MAX_CARDS,
|
||||||
|
MIN_CARDS,
|
||||||
|
sanitize_memory_config,
|
||||||
|
)
|
||||||
|
from .labels.mots_config import (
|
||||||
|
DEFAULT_MOTS_CONFIG,
|
||||||
|
MAX_WORDS,
|
||||||
|
MIN_WORDS,
|
||||||
|
sanitize_mots_config,
|
||||||
|
)
|
||||||
from .labels.quiz_config import (
|
from .labels.quiz_config import (
|
||||||
DEFAULT_QUIZ_CONFIG,
|
DEFAULT_QUIZ_CONFIG,
|
||||||
MAX_CHOICES,
|
MAX_CHOICES,
|
||||||
@@ -44,28 +64,77 @@ from .labels.quiz_config import (
|
|||||||
quiz_total_points,
|
quiz_total_points,
|
||||||
sanitize_quiz_config,
|
sanitize_quiz_config,
|
||||||
)
|
)
|
||||||
|
from .labels.sanitize_element_attributes import sanitize_element_attributes
|
||||||
|
from .labels.scenario_config import (
|
||||||
|
DEFAULT_SCENARIO_CONFIG,
|
||||||
|
MAX_SCENARIO_CHOICES,
|
||||||
|
sanitize_scenario_config,
|
||||||
|
)
|
||||||
|
from .pages.add_document_page import add_document_page
|
||||||
|
from .pages.delete_all_document_pages import delete_all_document_pages
|
||||||
|
from .pages.delete_document_page import delete_document_page
|
||||||
|
from .pages.get_document_page import get_document_page
|
||||||
|
from .pages.list_document_pages import list_document_pages
|
||||||
|
from .pages.move_document_page import move_document_page
|
||||||
|
from .pages.replace_document_content import replace_document_content
|
||||||
|
from .pages.set_document_page_vertical_align import VERTICAL_ALIGNS, set_document_page_vertical_align
|
||||||
|
from .pages.update_document_page import update_document_page
|
||||||
from .rendering.render_document_element import render_document, render_document_element
|
from .rendering.render_document_element import render_document, render_document_element
|
||||||
|
from .rendering.sanitize_svg_markup import sanitize_svg_markup
|
||||||
|
from .themes.seed_blocks_to_elements import seed_blocks_to_elements
|
||||||
|
from .themes.theme_catalog import DOCUMENT_THEMES, get_document_theme_entry
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
"CARD_MODES",
|
||||||
"CONTENT_KINDS",
|
"CONTENT_KINDS",
|
||||||
|
"DEFAULT_ASSOCIATION_CONFIG",
|
||||||
|
"DEFAULT_MEMORY_CONFIG",
|
||||||
|
"DEFAULT_MOTS_CONFIG",
|
||||||
"DEFAULT_QUIZ_CONFIG",
|
"DEFAULT_QUIZ_CONFIG",
|
||||||
|
"DEFAULT_SCENARIO_CONFIG",
|
||||||
|
"DOCUMENT_THEMES",
|
||||||
"ELEMENT_KIND_LABELS",
|
"ELEMENT_KIND_LABELS",
|
||||||
"ELEMENT_LIBRARY",
|
"ELEMENT_LIBRARY",
|
||||||
|
"MAX_CARDS",
|
||||||
"MAX_CHOICES",
|
"MAX_CHOICES",
|
||||||
|
"MAX_PAIRS",
|
||||||
|
"MAX_SCENARIO_CHOICES",
|
||||||
"MAX_TIMER_SECONDS",
|
"MAX_TIMER_SECONDS",
|
||||||
|
"MAX_WORDS",
|
||||||
|
"MIN_CARDS",
|
||||||
"MIN_CHOICES",
|
"MIN_CHOICES",
|
||||||
|
"MIN_PAIRS",
|
||||||
"MIN_TIMER_SECONDS",
|
"MIN_TIMER_SECONDS",
|
||||||
|
"MIN_WORDS",
|
||||||
"MINIGAME_KINDS",
|
"MINIGAME_KINDS",
|
||||||
"SHAPE_KINDS",
|
"VERTICAL_ALIGNS",
|
||||||
"add_document_element",
|
"add_document_element",
|
||||||
|
"add_document_page",
|
||||||
|
"delete_all_document_pages",
|
||||||
"delete_document_element",
|
"delete_document_element",
|
||||||
|
"delete_document_page",
|
||||||
"element_default_attributes",
|
"element_default_attributes",
|
||||||
"get_document_element",
|
"get_document_element",
|
||||||
|
"get_document_page",
|
||||||
|
"get_document_theme_entry",
|
||||||
"list_document_elements",
|
"list_document_elements",
|
||||||
|
"list_document_pages",
|
||||||
"move_document_element",
|
"move_document_element",
|
||||||
|
"move_document_element_to_page",
|
||||||
|
"move_document_page",
|
||||||
"quiz_total_points",
|
"quiz_total_points",
|
||||||
"render_document",
|
"render_document",
|
||||||
"render_document_element",
|
"render_document_element",
|
||||||
|
"replace_document_content",
|
||||||
|
"sanitize_association_config",
|
||||||
|
"sanitize_element_attributes",
|
||||||
|
"sanitize_memory_config",
|
||||||
|
"sanitize_mots_config",
|
||||||
"sanitize_quiz_config",
|
"sanitize_quiz_config",
|
||||||
|
"sanitize_scenario_config",
|
||||||
|
"sanitize_svg_markup",
|
||||||
|
"seed_blocks_to_elements",
|
||||||
|
"set_document_page_vertical_align",
|
||||||
"update_document_element_attributes",
|
"update_document_element_attributes",
|
||||||
|
"update_document_page",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,15 +1,19 @@
|
|||||||
# document_engine/
|
# document_engine/
|
||||||
|
|
||||||
Moteur du support de formation — entité racine séparée du jeu 2D (voir
|
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
|
`docs/plan/PLAN.md`). Package composé de cinq sous-dossiers, chacun
|
||||||
documenté séparément :
|
documenté séparément :
|
||||||
|
|
||||||
- [`elements/`](elements/elements.md) — CRUD des éléments du document
|
- [`elements/`](elements/elements.md) — CRUD des éléments du document
|
||||||
(`_document_elements`).
|
(`_document_elements`).
|
||||||
- [`labels/`](labels/labels.md) — catalogue statique des types d'éléments
|
- [`labels/`](labels/labels.md) — catalogue statique des types d'éléments
|
||||||
(bibliothèque, libellés, attributs par défaut).
|
(bibliothèque, libellés, attributs par défaut).
|
||||||
|
- [`pages/`](pages/pages.md) — CRUD des pages d'un support
|
||||||
|
(`_document_pages`) et remplacement complet du contenu depuis un thème.
|
||||||
- [`rendering/`](rendering/rendering.md) — rendu HTML du document (canevas
|
- [`rendering/`](rendering/rendering.md) — rendu HTML du document (canevas
|
||||||
d'édition et Mode Aperçu, même fonction).
|
d'édition et Mode Aperçu, même fonction).
|
||||||
|
- [`themes/`](themes/themes.md) — catalogue des thèmes visuels
|
||||||
|
applicables à un support (bouton "Utiliser un modèle").
|
||||||
|
|
||||||
`document_engine/__init__.py` ré-exporte l'intégralité de l'API publique
|
`document_engine/__init__.py` ré-exporte l'intégralité de l'API publique
|
||||||
du paquet (voir son `__all__`), pattern identique à `game_engine/__init__.py`.
|
du paquet (voir son `__all__`), pattern identique à `game_engine/__init__.py`.
|
||||||
|
|||||||
@@ -1,23 +1,29 @@
|
|||||||
import json
|
import json
|
||||||
|
|
||||||
from db.supports import connect_support
|
from db.supports import connect_support, ensure_document_pages_schema
|
||||||
|
|
||||||
from ..labels.element_kind_labels import element_default_attributes
|
from ..labels.element_kind_labels import element_default_attributes
|
||||||
|
|
||||||
|
|
||||||
def add_document_element(slug: str, kind: str, parent_id: int | None = None) -> int:
|
def add_document_element(slug: str, kind: str, page_id: int, parent_id: int | None = None) -> int:
|
||||||
"""Ajoute un élément en fin de son groupe de frères (même parent_id —
|
"""Ajoute un élément à une page précise, en fin de son groupe de
|
||||||
NULL pour un élément top-niveau, l'id d'une rangée pour un enfant de
|
frères (même parent_id — NULL pour un élément top-niveau, l'id d'une
|
||||||
cette rangée, voir docs/plan/PLAN.md). Attributs de départ posés via
|
rangée pour un enfant de cette rangée, voir docs/plan/PLAN.md).
|
||||||
element_default_attributes(kind)."""
|
Attributs de départ posés via element_default_attributes(kind). Le
|
||||||
|
groupe de frères (parent_id) est TOUJOURS cherché à l'intérieur de
|
||||||
|
cette même page — deux pages peuvent chacune avoir une rangée dont
|
||||||
|
les enfants portent des id `parent_id` différents, jamais de
|
||||||
|
confusion possible entre pages."""
|
||||||
|
ensure_document_pages_schema(slug)
|
||||||
conn = connect_support(slug)
|
conn = connect_support(slug)
|
||||||
max_row = conn.execute(
|
max_row = conn.execute(
|
||||||
"SELECT MAX(order_index) AS m FROM _document_elements WHERE parent_id IS ?", (parent_id,)
|
"SELECT MAX(order_index) AS m FROM _document_elements WHERE page_id = ? AND parent_id IS ?",
|
||||||
|
(page_id, parent_id),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
order_index = (max_row["m"] or 0) + 1 if max_row and max_row["m"] is not None else 0
|
order_index = (max_row["m"] or 0) + 1 if max_row and max_row["m"] is not None else 0
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO _document_elements (parent_id, kind, order_index, attributes) VALUES (?, ?, ?, ?)",
|
"INSERT INTO _document_elements (page_id, parent_id, kind, order_index, attributes) VALUES (?, ?, ?, ?, ?)",
|
||||||
(parent_id, kind, order_index, json.dumps(element_default_attributes(kind))),
|
(page_id, parent_id, kind, order_index, json.dumps(element_default_attributes(kind))),
|
||||||
)
|
)
|
||||||
element_id = int(conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"])
|
element_id = int(conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"])
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
from db.supports import connect_support
|
from db.supports import connect_support, ensure_document_pages_schema
|
||||||
|
|
||||||
|
|
||||||
def delete_document_element(slug: str, element_id: int) -> None:
|
def delete_document_element(slug: str, element_id: int) -> None:
|
||||||
"""Supprime un élément — CASCADE (contrainte FK, voir
|
"""Supprime un élément — CASCADE (contrainte FK, voir
|
||||||
db/supports/create_support.py) retire aussi ses enfants si c'était une
|
db/supports/create_support.py) retire aussi ses enfants si c'était une
|
||||||
rangée."""
|
rangée."""
|
||||||
|
ensure_document_pages_schema(slug)
|
||||||
conn = connect_support(slug)
|
conn = connect_support(slug)
|
||||||
conn.execute("DELETE FROM _document_elements WHERE id = ?", (element_id,))
|
conn.execute("DELETE FROM _document_elements WHERE id = ?", (element_id,))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|||||||
@@ -1,23 +1,29 @@
|
|||||||
# document_engine/elements/
|
# document_engine/elements/
|
||||||
|
|
||||||
CRUD des éléments d'un support de formation (`_document_elements`, voir
|
CRUD des éléments d'UNE PAGE d'un support de formation
|
||||||
`db/supports/create_support.py`). Un élément est soit un élément de
|
(`_document_elements`, voir `db/supports/create_support.py`) — un
|
||||||
contenu/rangée du flux (`parent_id` = groupe de frères), soit une forme
|
support est composé de plusieurs pages (voir `document_engine/pages/`),
|
||||||
libre superposée en position absolue (toujours `parent_id = NULL`).
|
chaque élément appartient à exactement une page via `page_id`, jamais
|
||||||
|
partagé entre pages. Un élément est soit un élément de contenu/rangée du
|
||||||
|
flux (`parent_id` = groupe de frères, toujours dans la MÊME page), 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`
|
## `add_document_element(slug: str, kind: str, page_id: int, parent_id: int | None = None) -> int`
|
||||||
Insère un nouvel élément en fin de son groupe de frères (même `parent_id`).
|
Insère un nouvel élément dans `page_id`, en fin de son groupe de frères
|
||||||
|
(même `parent_id`, recherché uniquement à l'intérieur de cette page).
|
||||||
Pose les attributs de départ via `element_default_attributes(kind)`
|
Pose les attributs de départ via `element_default_attributes(kind)`
|
||||||
(voir `document_engine/labels/element_kind_labels.py`).
|
(voir `document_engine/labels/element_kind_labels.py`).
|
||||||
- **Retour** : l'`id` du nouvel élément.
|
- **Retour** : l'`id` du nouvel élément.
|
||||||
- **Exceptions** : aucune levée explicitement ; une connexion invalide
|
- **Exceptions** : aucune levée explicitement ; une connexion invalide
|
||||||
(support inexistant) lève l'erreur SQLite sous-jacente.
|
(support inexistant) lève l'erreur SQLite sous-jacente.
|
||||||
|
|
||||||
## `list_document_elements(slug: str) -> list[dict[str, Any]]`
|
## `list_document_elements(slug: str, page_id: int) -> list[dict[str, Any]]`
|
||||||
Renvoie tous les éléments du support, à plat, triés par `(parent_id,
|
Renvoie tous les éléments de `page_id`, à plat, triés par `(parent_id,
|
||||||
order_index)` — les éléments top-niveau (`parent_id` NULL) groupés en
|
order_index)` — les éléments top-niveau (`parent_id` NULL) groupés en
|
||||||
premier (tri SQLite : NULL avant toute valeur), puis chaque rangée
|
premier (tri SQLite : NULL avant toute valeur), puis chaque rangée
|
||||||
groupant ses propres enfants. `attributes` est décodé en dict.
|
groupant ses propres enfants. `attributes` est décodé en dict. Le
|
||||||
|
canevas n'affiche jamais qu'une seule page à la fois, d'où ce filtrage.
|
||||||
- **Retour** : liste de dicts (une ligne de table chacun, `attributes`
|
- **Retour** : liste de dicts (une ligne de table chacun, `attributes`
|
||||||
déjà en JSON décodé).
|
déjà en JSON décodé).
|
||||||
- **Exceptions** : aucune.
|
- **Exceptions** : aucune.
|
||||||
@@ -42,7 +48,28 @@ 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
|
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
|
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
|
si le parent change, un seul sinon) pour rester correct même en cas de
|
||||||
réordonnancement dans le même groupe.
|
réordonnancement dans le même groupe. `new_parent_id` doit toujours
|
||||||
|
désigner un élément de la MÊME page que `element_id` — jamais vérifié
|
||||||
|
ici (le client ne propose que des cibles de la page actuellement
|
||||||
|
affichée).
|
||||||
|
- **Retour** : aucun.
|
||||||
|
- **Exceptions** : aucune levée explicitement ; un `element_id`
|
||||||
|
inexistant ne fait rien (retour silencieux après vérification de son
|
||||||
|
existence).
|
||||||
|
|
||||||
|
## `move_document_element_to_page(slug: str, element_id: int, target_page_id: int) -> None`
|
||||||
|
Déplace un élément vers une AUTRE page du même support — utilisé par la
|
||||||
|
pagination automatique (retour utilisateur du 23/09/2026 : quand le
|
||||||
|
contenu déborde d'une page, l'élément en trop est déplacé vers une
|
||||||
|
nouvelle page plutôt que d'y rester tassé). L'élément redevient TOUJOURS
|
||||||
|
top-niveau sur la page cible (`parent_id` remis à `NULL`) — une rangée
|
||||||
|
qui existait sur l'ancienne page n'a aucun sens comme enfant d'une
|
||||||
|
rangée de la page cible. Si l'élément déplacé est lui-même une rangée,
|
||||||
|
ses enfants directs (même `parent_id`) SUIVENT sur la page cible
|
||||||
|
(`page_id` mis à jour en cascade, `parent_id` inchangé) — sans cette
|
||||||
|
cascade ils resteraient orphelins d'une page qu'ils n'occupent plus
|
||||||
|
(`list_document_elements`, filtré par `page_id`, ne les retrouverait
|
||||||
|
plus). Renumérote les anciens frères après le retrait.
|
||||||
- **Retour** : aucun.
|
- **Retour** : aucun.
|
||||||
- **Exceptions** : aucune levée explicitement ; un `element_id`
|
- **Exceptions** : aucune levée explicitement ; un `element_id`
|
||||||
inexistant ne fait rien (retour silencieux après vérification de son
|
inexistant ne fait rien (retour silencieux après vérification de son
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import json
|
import json
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from db.supports import connect_support
|
from db.supports import connect_support, ensure_document_pages_schema
|
||||||
|
|
||||||
|
|
||||||
def get_document_element(slug: str, element_id: int) -> dict[str, Any] | None:
|
def get_document_element(slug: str, element_id: int) -> dict[str, Any] | None:
|
||||||
|
ensure_document_pages_schema(slug)
|
||||||
conn = connect_support(slug)
|
conn = connect_support(slug)
|
||||||
row = conn.execute("SELECT * FROM _document_elements WHERE id = ?", (element_id,)).fetchone()
|
row = conn.execute("SELECT * FROM _document_elements WHERE id = ?", (element_id,)).fetchone()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|||||||
@@ -1,21 +1,26 @@
|
|||||||
import json
|
import json
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from db.supports import connect_support
|
from db.supports import connect_support, ensure_document_pages_schema
|
||||||
|
|
||||||
|
|
||||||
def list_document_elements(slug: str) -> list[dict[str, Any]]:
|
def list_document_elements(slug: str, page_id: int) -> list[dict[str, Any]]:
|
||||||
"""Tous les éléments d'un support, à PLAT — chaque élément porte son
|
"""Tous les éléments d'UNE PAGE du support, à PLAT — chaque élément
|
||||||
propre parent_id (NULL = top-niveau, sinon l'id d'une rangée) ; la
|
porte son propre parent_id (NULL = top-niveau, sinon l'id d'une
|
||||||
reconstitution de l'arbre (rangées + leurs enfants dans l'ordre) se
|
rangée) ; la reconstitution de l'arbre (rangées + leurs enfants dans
|
||||||
fait côté rendu (voir document_engine.render_document_element) et
|
l'ordre) se fait côté rendu (voir document_engine.render_document_element)
|
||||||
côté JS pour l'affichage du canevas."""
|
et côté JS pour l'affichage du canevas. Un support est composé de
|
||||||
|
plusieurs pages (voir document_engine/pages/) : le canevas n'affiche
|
||||||
|
jamais qu'une seule page à la fois, d'où ce filtrage par page_id."""
|
||||||
|
ensure_document_pages_schema(slug)
|
||||||
conn = connect_support(slug)
|
conn = connect_support(slug)
|
||||||
# SQLite trie NULL avant toute valeur : les éléments top-niveau
|
# SQLite trie NULL avant toute valeur : les éléments top-niveau
|
||||||
# (parent_id NULL) arrivent groupés en premier, puis chaque rangée
|
# (parent_id NULL) arrivent groupés en premier, puis chaque rangée
|
||||||
# groupe ses propres enfants — chacun trié par order_index à
|
# groupe ses propres enfants — chacun trié par order_index à
|
||||||
# l'intérieur de son groupe.
|
# l'intérieur de son groupe.
|
||||||
rows = conn.execute("SELECT * FROM _document_elements ORDER BY parent_id, order_index").fetchall()
|
rows = conn.execute(
|
||||||
|
"SELECT * FROM _document_elements WHERE page_id = ? ORDER BY parent_id, order_index", (page_id,)
|
||||||
|
).fetchall()
|
||||||
conn.close()
|
conn.close()
|
||||||
elements = []
|
elements = []
|
||||||
for row in rows:
|
for row in rows:
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from db.supports import connect_support
|
from db.supports import connect_support, ensure_document_pages_schema
|
||||||
|
|
||||||
|
|
||||||
def move_document_element(slug: str, element_id: int, new_parent_id: int | None, new_index: int) -> None:
|
def move_document_element(slug: str, element_id: int, new_parent_id: int | None, new_index: int) -> None:
|
||||||
@@ -8,7 +8,11 @@ def move_document_element(slug: str, element_id: int, new_parent_id: int | None,
|
|||||||
une position précise, pas juste "monter/descendre" d'un cran. Renumérote
|
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
|
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
|
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."""
|
en cas de réordonnancement DANS le même groupe. new_parent_id doit
|
||||||
|
toujours désigner un élément de la MÊME page que element_id (jamais
|
||||||
|
vérifié ici — le client ne propose que des cibles de la page
|
||||||
|
actuellement affichée, voir static/document/js/document-editor.js)."""
|
||||||
|
ensure_document_pages_schema(slug)
|
||||||
conn = connect_support(slug)
|
conn = connect_support(slug)
|
||||||
row = conn.execute("SELECT parent_id FROM _document_elements WHERE id = ?", (element_id,)).fetchone()
|
row = conn.execute("SELECT parent_id FROM _document_elements WHERE id = ?", (element_id,)).fetchone()
|
||||||
if not row:
|
if not row:
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
from db.supports import connect_support, ensure_document_pages_schema
|
||||||
|
|
||||||
|
|
||||||
|
def move_document_element_to_page(slug: str, element_id: int, target_page_id: int) -> None:
|
||||||
|
"""Déplace un élément vers une AUTRE page du même support — utilisé
|
||||||
|
par la pagination automatique (retour utilisateur du 23/09/2026 :
|
||||||
|
"si il n'y a plus de place sur la page il faut automatiquement créer
|
||||||
|
une autre page [et y déplacer] le contenu", voir
|
||||||
|
static/document/js/document-editor.js, forgeDocCheckPageOverflow).
|
||||||
|
L'élément redevient TOUJOURS top-niveau sur la page cible (parent_id
|
||||||
|
NULL) — une rangée qui existait sur l'ancienne page n'a aucun sens
|
||||||
|
comme enfant d'une rangée de la page cible. Si l'élément déplacé est
|
||||||
|
lui-même une rangée, ses enfants directs (même parent_id) SUIVENT sur
|
||||||
|
la page cible (cascade sur page_id, parent_id inchangé) : sans cette
|
||||||
|
cascade, list_document_elements (filtré par page_id) ne les
|
||||||
|
retrouverait plus, alors qu'ils resteraient en base rattachés à une
|
||||||
|
rangée désormais sur une autre page — état incohérent silencieux."""
|
||||||
|
ensure_document_pages_schema(slug)
|
||||||
|
conn = connect_support(slug)
|
||||||
|
row = conn.execute("SELECT page_id, parent_id FROM _document_elements WHERE id = ?", (element_id,)).fetchone()
|
||||||
|
if not row:
|
||||||
|
conn.close()
|
||||||
|
return
|
||||||
|
old_page_id = row["page_id"]
|
||||||
|
old_parent_id = row["parent_id"]
|
||||||
|
|
||||||
|
old_siblings = [
|
||||||
|
r["id"]
|
||||||
|
for r in conn.execute(
|
||||||
|
"SELECT id FROM _document_elements WHERE page_id = ? AND parent_id IS ? AND id != ? ORDER BY order_index",
|
||||||
|
(old_page_id, 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_top_level_count = conn.execute(
|
||||||
|
"SELECT COUNT(*) AS n FROM _document_elements WHERE page_id = ? AND parent_id IS NULL", (target_page_id,)
|
||||||
|
).fetchone()["n"]
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE _document_elements SET page_id = ?, parent_id = NULL, order_index = ? WHERE id = ?",
|
||||||
|
(target_page_id, new_top_level_count, element_id),
|
||||||
|
)
|
||||||
|
conn.execute("UPDATE _document_elements SET page_id = ? WHERE parent_id = ?", (target_page_id, element_id))
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import json
|
import json
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from db.supports import connect_support
|
from db.supports import connect_support, ensure_document_pages_schema
|
||||||
|
|
||||||
|
|
||||||
def update_document_element_attributes(slug: str, element_id: int, attributes: dict[str, Any]) -> None:
|
def update_document_element_attributes(slug: str, element_id: int, attributes: dict[str, Any]) -> None:
|
||||||
@@ -9,6 +9,7 @@ def update_document_element_attributes(slug: str, element_id: int, attributes: d
|
|||||||
Propriétés envoie systématiquement l'état entier de ses champs, jamais
|
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
|
un patch partiel (voir docs/plan/PLAN.md §1.2.D : "chaque formulaire
|
||||||
est autonome")."""
|
est autonome")."""
|
||||||
|
ensure_document_pages_schema(slug)
|
||||||
conn = connect_support(slug)
|
conn = connect_support(slug)
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE _document_elements SET attributes = ? WHERE id = ?",
|
"UPDATE _document_elements SET attributes = ? WHERE id = ?",
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
"""Modèle de données du mini-jeu Association (voir docs/plan/PLAN.md §3.2)
|
||||||
|
— l'apprenant relie chaque carte de gauche à sa correspondance de droite
|
||||||
|
par glisser-déposer (voir document_engine/rendering/render_document_element.py
|
||||||
|
::_render_association_player). Même convention resolve_X/sanitize_X que
|
||||||
|
quiz_config.py et game_engine/rendering/quiz_box_config.py côté jeu
|
||||||
|
(aucun import croisé)."""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
MIN_PAIRS = 2
|
||||||
|
MAX_PAIRS = 8
|
||||||
|
|
||||||
|
DEFAULT_ASSOCIATION_CONFIG: dict[str, Any] = {
|
||||||
|
"theme_color": "#ff5f2e",
|
||||||
|
"pairs": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_pair(raw: Any) -> dict[str, str] | None:
|
||||||
|
"""None si la paire est invalide (un des deux côtés vide) — filtrée par
|
||||||
|
sanitize_association_config plutôt que de faire échouer tout le
|
||||||
|
mini-jeu, même convention que quiz_config.py::_sanitize_question."""
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
return None
|
||||||
|
left = str(raw.get("left", "")).strip()
|
||||||
|
right = str(raw.get("right", "")).strip()
|
||||||
|
if not left or not right:
|
||||||
|
return None
|
||||||
|
return {"left": left, "right": right}
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_association_config(raw_config: Any) -> dict[str, Any]:
|
||||||
|
config = dict(DEFAULT_ASSOCIATION_CONFIG)
|
||||||
|
if not isinstance(raw_config, dict):
|
||||||
|
return config
|
||||||
|
theme_color = raw_config.get("theme_color")
|
||||||
|
if isinstance(theme_color, str) and theme_color:
|
||||||
|
config["theme_color"] = theme_color
|
||||||
|
raw_pairs = raw_config.get("pairs")
|
||||||
|
if isinstance(raw_pairs, list):
|
||||||
|
pairs = [p for p in (_sanitize_pair(item) for item in raw_pairs) if p is not None]
|
||||||
|
config["pairs"] = pairs[:MAX_PAIRS]
|
||||||
|
return config
|
||||||
@@ -5,31 +5,34 @@ défaut posés à la création de chaque type."""
|
|||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from ..rendering.box_style import BOX_DEFAULTS, default_border
|
||||||
|
from .association_config import DEFAULT_ASSOCIATION_CONFIG
|
||||||
|
from .memory_config import DEFAULT_MEMORY_CONFIG
|
||||||
|
from .mots_config import DEFAULT_MOTS_CONFIG
|
||||||
from .quiz_config import DEFAULT_QUIZ_CONFIG
|
from .quiz_config import DEFAULT_QUIZ_CONFIG
|
||||||
|
from .scenario_config import DEFAULT_SCENARIO_CONFIG
|
||||||
|
|
||||||
SHAPE_KINDS = ("rectangle", "cercle", "triangle", "trait")
|
CONTENT_KINDS = ("titre", "paragraphe", "image", "bouton", "liste_puces", "liste_numerotee", "badge", "carte")
|
||||||
CONTENT_KINDS = ("titre", "paragraphe", "image", "bouton")
|
|
||||||
MINIGAME_KINDS = ("quiz", "association", "memory", "mots", "scenario", "zones")
|
MINIGAME_KINDS = ("quiz", "association", "memory", "mots", "scenario", "zones")
|
||||||
|
|
||||||
# "row" n'apparaît jamais dans la bibliothèque (créé implicitement par le
|
# "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
|
# 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, présent dans ELEMENT_KIND_LABELS pour l'affichage/debug.
|
||||||
ELEMENT_LIBRARY: dict[str, dict[str, Any]] = {
|
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)},
|
"contenu": {"label": "Contenu", "kinds": list(CONTENT_KINDS)},
|
||||||
"minigames": {"label": "Mini-jeux", "kinds": list(MINIGAME_KINDS)},
|
"minigames": {"label": "Mini-jeux", "kinds": list(MINIGAME_KINDS)},
|
||||||
}
|
}
|
||||||
|
|
||||||
ELEMENT_KIND_LABELS: dict[str, str] = {
|
ELEMENT_KIND_LABELS: dict[str, str] = {
|
||||||
"row": "Rangée",
|
"row": "Rangée",
|
||||||
"rectangle": "Rectangle",
|
|
||||||
"cercle": "Cercle",
|
|
||||||
"triangle": "Triangle",
|
|
||||||
"trait": "Trait",
|
|
||||||
"titre": "Titre",
|
"titre": "Titre",
|
||||||
"paragraphe": "Paragraphe",
|
"paragraphe": "Paragraphe",
|
||||||
"image": "Image",
|
"image": "Image",
|
||||||
"bouton": "Bouton",
|
"bouton": "Bouton",
|
||||||
|
"liste_puces": "Liste à puces",
|
||||||
|
"liste_numerotee": "Liste numérotée",
|
||||||
|
"badge": "Étiquette",
|
||||||
|
"carte": "Carte",
|
||||||
"quiz": "Quiz",
|
"quiz": "Quiz",
|
||||||
"association": "Association",
|
"association": "Association",
|
||||||
"memory": "Memory",
|
"memory": "Memory",
|
||||||
@@ -38,25 +41,28 @@ ELEMENT_KIND_LABELS: dict[str, str] = {
|
|||||||
"zones": "Zones à risque",
|
"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 = {
|
_TEXT_DEFAULTS = {
|
||||||
"bold": False,
|
"bold": False,
|
||||||
"italic": False,
|
"italic": False,
|
||||||
"underline": False,
|
"underline": False,
|
||||||
|
"strikethrough": False,
|
||||||
"align": "left",
|
"align": "left",
|
||||||
"color": "var(--forge-text)",
|
"color": "var(--forge-text)",
|
||||||
|
# Vides par défaut = valeurs du préréglage `style` inchangées (voir
|
||||||
|
# _STYLE_PRESETS dans render_document_element.py) — une valeur
|
||||||
|
# explicite les remplace (retour utilisateur du 26/09/2026, audit des
|
||||||
|
# réglages manquants).
|
||||||
|
"font_family": "",
|
||||||
|
"font_size": "",
|
||||||
|
"line_height": "",
|
||||||
|
"letter_spacing": "",
|
||||||
|
"text_transform": "none",
|
||||||
|
"text_shadow": "",
|
||||||
|
# Attributs de boîte partagés avec d'autres kinds (dont `max_width`,
|
||||||
|
# ex. "60ch"/"480px" — retour utilisateur du 24/09/2026 : un
|
||||||
|
# paragraphe doit pouvoir rester plus étroit que la page) — voir
|
||||||
|
# rendering/box_style.py.
|
||||||
|
**BOX_DEFAULTS,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -64,30 +70,184 @@ def element_default_attributes(kind: str) -> dict[str, Any]:
|
|||||||
"""Attributs posés à la création d'un élément de ce type — cf.
|
"""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
|
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."""
|
éditables par panneau, ici juste leur valeur de départ."""
|
||||||
if kind in SHAPE_KINDS:
|
|
||||||
return dict(_SHAPE_DEFAULTS)
|
|
||||||
if kind == "titre":
|
if kind == "titre":
|
||||||
return {"content": "Nouveau titre", "style": "titre1", **_TEXT_DEFAULTS}
|
# "border" copié à chaque appel (jamais un dict partagé/muté par
|
||||||
|
# référence entre plusieurs éléments — même raison que la copie
|
||||||
|
# de "questions" pour le quiz plus bas).
|
||||||
|
return {"content": "Nouveau titre", "style": "titre1", **_TEXT_DEFAULTS, "border": default_border()}
|
||||||
if kind == "paragraphe":
|
if kind == "paragraphe":
|
||||||
return {"content": "Nouveau paragraphe de texte.", "style": "paragraphe", **_TEXT_DEFAULTS}
|
return {
|
||||||
|
"content": "Nouveau paragraphe de texte.",
|
||||||
|
"style": "paragraphe",
|
||||||
|
**_TEXT_DEFAULTS,
|
||||||
|
"border": default_border(),
|
||||||
|
}
|
||||||
if kind == "image":
|
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é.
|
||||||
|
# aspect_ratio/filter_preset : vides par défaut (aucun style
|
||||||
|
# ajouté). click_behavior/link_url/lazy_load/caption :
|
||||||
|
# comportement/contenu, pas du style (retour utilisateur du
|
||||||
|
# 26/09/2026, audit des réglages manquants — colonne "Image").
|
||||||
|
# click_behavior ("" | "link" | "lightbox") et link_url sont
|
||||||
|
# mutuellement dépendants (un lien sans URL ne fait rien au
|
||||||
|
# rendu, voir _render_image) mais jamais revalidés l'un par
|
||||||
|
# rapport à l'autre ici : cette combinaison reste sans risque
|
||||||
|
# quelle qu'elle soit.
|
||||||
|
#
|
||||||
|
# object_fit="cover" + height="220px" (au lieu de vides) : cadre
|
||||||
|
# FIXE par défaut (retour utilisateur du 26/09/2026 : "ce cadre
|
||||||
|
# ne devrait pas changer de taille en fonction de la taille de
|
||||||
|
# l'image mais être fixe et contraindre l'image dedans") — une
|
||||||
|
# photo importée est désormais TOUJOURS rognée pour remplir ce
|
||||||
|
# cadre, quelle que soit sa résolution native, plutôt que de
|
||||||
|
# dicter elle-même la taille du bloc. "Taille réelle" reste
|
||||||
|
# sélectionnable explicitement dans le panneau Propriétés
|
||||||
|
# (segmented "Ajustement dans son cadre") pour qui préfère
|
||||||
|
# revenir à l'ancien comportement (hauteur libre, aucun
|
||||||
|
# object-fit) ; un défaut CSS aveugle sur TOUTE image aurait
|
||||||
|
# rendu ce choix impossible à distinguer de "jamais réglé", les
|
||||||
|
# deux valant la chaîne vide.
|
||||||
|
return {
|
||||||
|
"src": "",
|
||||||
|
"alt": "",
|
||||||
|
"svg_markup": "",
|
||||||
|
"object_fit": "cover",
|
||||||
|
"aspect_ratio": "",
|
||||||
|
"filter_preset": "",
|
||||||
|
"click_behavior": "",
|
||||||
|
"link_url": "",
|
||||||
|
"lazy_load": False,
|
||||||
|
"caption": "",
|
||||||
|
**BOX_DEFAULTS,
|
||||||
|
"height": "220px",
|
||||||
|
"border": default_border(),
|
||||||
|
}
|
||||||
if kind == "bouton":
|
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.
|
||||||
|
#
|
||||||
|
# Audit du 26/09/2026 (réglages manquants — colonne "Bouton") :
|
||||||
|
# bold/italic/text_transform/font_family/font_size/letter_spacing/
|
||||||
|
# text_color (typo, jamais gérés par box_style.py) + svg_markup/
|
||||||
|
# icon_position/icon_size (icône avant/après le texte, absente
|
||||||
|
# même de l'étiquette qui n'a qu'un SVG fixe sans position ni
|
||||||
|
# taille réglables) + les attributs de boîte partagés. bold=False
|
||||||
|
# PAR DÉFAUT (le CSS de base garde son font-weight:700 tel quel
|
||||||
|
# tant que "bold" n'est pas explicitement activé — voir
|
||||||
|
# _render_button, qui ne pousse à 800 QUE si bold=True — aucune
|
||||||
|
# régression visuelle sur les boutons déjà créés).
|
||||||
|
return {
|
||||||
|
"label": "Bouton",
|
||||||
|
"target": "",
|
||||||
|
"attachment_stored_name": "",
|
||||||
|
"attachment_filename": "",
|
||||||
|
"bold": False,
|
||||||
|
"italic": False,
|
||||||
|
"text_transform": "none",
|
||||||
|
"font_family": "",
|
||||||
|
"font_size": "",
|
||||||
|
"letter_spacing": "",
|
||||||
|
"text_color": "",
|
||||||
|
"svg_markup": "",
|
||||||
|
"icon_position": "before",
|
||||||
|
"icon_size": "",
|
||||||
|
**BOX_DEFAULTS,
|
||||||
|
"border": default_border(),
|
||||||
|
}
|
||||||
|
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.
|
||||||
|
#
|
||||||
|
# Audit du 26/09/2026 (réglages manquants — colonne "Liste à
|
||||||
|
# puces/numérotée") : portée actée avec l'utilisateur = style de
|
||||||
|
# la LISTE ENTIÈRE (typo/puces/boîte/bordure/fond), jamais un
|
||||||
|
# style par élément individuel ni de sous-listes (chantier bien
|
||||||
|
# plus lourd, transformerait `items` d'une liste de chaînes en
|
||||||
|
# objets structurés — différé à une demande séparée). Seule
|
||||||
|
# exception : `item_padding`, un padding UNIFORME appliqué à
|
||||||
|
# CHAQUE élément (retour utilisateur explicite : "il faut un
|
||||||
|
# padding de base par élément de liste car y en a pas
|
||||||
|
# aujourd'hui") — une valeur PARTAGÉE par tous les éléments,
|
||||||
|
# jamais réglable individuellement, voir _render_list.
|
||||||
|
# list_style_type : valide uniquement parmi les valeurs propres
|
||||||
|
# au kind (disc/circle/square/none pour puces,
|
||||||
|
# decimal/.../none pour numérotée) — vérifié au rendu, jamais ici.
|
||||||
|
# svg_markup (puce personnalisée) : ignoré au rendu pour
|
||||||
|
# liste_numerotee (une puce imagée n'a pas de sens sur une liste
|
||||||
|
# numérotée, voir _render_list).
|
||||||
|
return {
|
||||||
|
"items": ["Premier élément", "Deuxième élément"],
|
||||||
|
"bold": False,
|
||||||
|
"italic": False,
|
||||||
|
"underline": False,
|
||||||
|
"font_family": "",
|
||||||
|
"font_size": "",
|
||||||
|
"line_height": "",
|
||||||
|
"text_color": "",
|
||||||
|
"list_style_type": "",
|
||||||
|
"list_style_position": "outside",
|
||||||
|
"marker_color": "",
|
||||||
|
"marker_size": "",
|
||||||
|
"svg_markup": "",
|
||||||
|
"item_padding": "6px",
|
||||||
|
"item_spacing": "",
|
||||||
|
**BOX_DEFAULTS,
|
||||||
|
"border": default_border(),
|
||||||
|
}
|
||||||
|
if kind == "badge":
|
||||||
|
# Même esprit que titre/paragraphe (bold/align/color... déjà des
|
||||||
|
# attributs par élément, pas des choix figés par le moteur) :
|
||||||
|
# svg_markup/width/border_radius/bold/uppercase vides ou False par
|
||||||
|
# défaut = comportement historique inchangé (pleine largeur, sans
|
||||||
|
# icône, casse normale) ; un thème ou le créateur les règle au cas
|
||||||
|
# par cas (retour utilisateur du 24/09/2026).
|
||||||
|
return {
|
||||||
|
"content": "Étiquette",
|
||||||
|
"svg_markup": "",
|
||||||
|
"width": "",
|
||||||
|
"border_radius": "",
|
||||||
|
"bold": False,
|
||||||
|
"uppercase": False,
|
||||||
|
}
|
||||||
|
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":
|
if kind == "row":
|
||||||
return {"gap": 16, "align": "stretch", "justify": "flex-start"}
|
return {"gap": 16, "align": "stretch", "justify": "flex-start"}
|
||||||
if kind == "quiz":
|
if kind == "quiz":
|
||||||
# Seul mini-jeu implémenté pour l'instant (voir quiz_config.py) —
|
|
||||||
# les autres restent un simple emplacement réservé ci-dessous.
|
|
||||||
# Copie de "questions" (liste, jamais un simple dict(...) qui la
|
# Copie de "questions" (liste, jamais un simple dict(...) qui la
|
||||||
# partagerait par référence avec DEFAULT_QUIZ_CONFIG) : aucun appelant
|
# partagerait par référence avec DEFAULT_QUIZ_CONFIG) : aucun appelant
|
||||||
# ne la mute en place aujourd'hui, mais la copier ici coûte rien et
|
# ne la mute en place aujourd'hui, mais la copier ici coûte rien et
|
||||||
# évite d'ancrer cette hypothèse fragile pour la suite.
|
# évite d'ancrer cette hypothèse fragile pour la suite.
|
||||||
return {**DEFAULT_QUIZ_CONFIG, "questions": list(DEFAULT_QUIZ_CONFIG["questions"])}
|
return {**DEFAULT_QUIZ_CONFIG, "questions": list(DEFAULT_QUIZ_CONFIG["questions"])}
|
||||||
|
if kind == "association":
|
||||||
|
# Même raison de copie que "quiz" ci-dessus (voir association_config.py).
|
||||||
|
return {**DEFAULT_ASSOCIATION_CONFIG, "pairs": list(DEFAULT_ASSOCIATION_CONFIG["pairs"])}
|
||||||
|
if kind == "memory":
|
||||||
|
# Même raison de copie que "quiz"/"association" ci-dessus (voir memory_config.py).
|
||||||
|
return {**DEFAULT_MEMORY_CONFIG, "cards": list(DEFAULT_MEMORY_CONFIG["cards"])}
|
||||||
|
if kind == "mots":
|
||||||
|
# Même raison de copie que "quiz"/"association"/"memory" ci-dessus (voir mots_config.py).
|
||||||
|
return {**DEFAULT_MOTS_CONFIG, "words": list(DEFAULT_MOTS_CONFIG["words"])}
|
||||||
|
if kind == "scenario":
|
||||||
|
# Même raison de copie que ci-dessus (voir scenario_config.py).
|
||||||
|
return {**DEFAULT_SCENARIO_CONFIG, "scenarios": list(DEFAULT_SCENARIO_CONFIG["scenarios"])}
|
||||||
if kind in MINIGAME_KINDS:
|
if kind in MINIGAME_KINDS:
|
||||||
# Panneau Propriétés minimal (voir PLAN.md §3.5, dernier
|
# Panneau Propriétés minimal (voir PLAN.md §3.5, dernier
|
||||||
# paragraphe : "état par défaut en attendant sa spécification") —
|
# paragraphe : "état par défaut en attendant sa spécification") —
|
||||||
# décision actée : cœur complet + mini-jeux en emplacement
|
# décision actée : cœur complet + mini-jeux en emplacement
|
||||||
# réservé, formulaires de contenu dédiés = chantier séparé.
|
# réservé (mots/scenario/zones), formulaires de contenu dédiés =
|
||||||
|
# chantier séparé.
|
||||||
return {"theme_color": "#ff5f2e"}
|
return {"theme_color": "#ff5f2e"}
|
||||||
return {}
|
return {}
|
||||||
|
|||||||
@@ -4,26 +4,38 @@ Catalogue statique des types d'éléments du support de formation : bibliothèqu
|
|||||||
groupée par catégorie (panneau gauche de l'éditeur), libellés d'affichage, et
|
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.
|
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, ...]`
|
## `CONTENT_KINDS: tuple[str, ...]`
|
||||||
`("titre", "paragraphe", "image", "bouton")` — éléments du flux, peuvent
|
`("titre", "paragraphe", "image", "bouton", "liste_puces",
|
||||||
être top-niveau ou enfants d'une rangée.
|
"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 `<ul>` ou `<ol>` au rendu (voir
|
||||||
|
`document_engine/rendering/render_document_element.py::_render_list`),
|
||||||
|
jamais un attribut `ordered` redondant. `"carte"` reste 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). `"badge"` a depuis gagné des
|
||||||
|
attributs de mise en forme PAR ÉLÉMENT (`svg_markup`/`width`/
|
||||||
|
`border_radius`/`bold`/`uppercase`, retour utilisateur du 24/09/2026)
|
||||||
|
— même esprit que `bold`/`align`/`color` déjà présents sur
|
||||||
|
`"titre"`/`"paragraphe"` : des réglages posés par le créateur ou un
|
||||||
|
thème au cas par cas, jamais une valeur figée dans le moteur. 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, ...]`
|
## `MINIGAME_KINDS: tuple[str, ...]`
|
||||||
`("quiz", "association", "memory", "mots", "scenario", "zones")` — seul
|
`("quiz", "association", "memory", "mots", "scenario", "zones")` —
|
||||||
`"quiz"` est implémenté (voir `quiz_config.py` ci-dessous) ; les 5 autres
|
`"quiz"`, `"association"` et `"memory"` sont implémentés (voir
|
||||||
gardent un panneau Propriétés minimal (emplacement réservé), formulaires
|
`quiz_config.py`/`association_config.py`/`memory_config.py` ci-dessous) ;
|
||||||
de contenu dédiés hors périmètre de cette passe.
|
les 3 autres gardent un panneau Propriétés minimal (emplacement réservé),
|
||||||
|
formulaires de contenu dédiés hors périmètre de cette passe.
|
||||||
|
|
||||||
## `ELEMENT_LIBRARY: dict[str, dict[str, Any]]`
|
## `ELEMENT_LIBRARY: dict[str, dict[str, Any]]`
|
||||||
Bibliothèque affichée dans le panneau gauche, groupée par catégorie
|
Bibliothèque affichée dans le panneau gauche, groupée par catégorie
|
||||||
(`mise_en_page` / `contenu` / `minigames`), chaque entrée portant un
|
(`contenu` / `minigames`), chaque entrée portant un `label` et sa liste
|
||||||
`label` et sa liste de `kinds`. `"row"` n'y apparaît jamais — créé
|
de `kinds`. `"row"` n'y apparaît jamais — créé implicitement par le
|
||||||
implicitement par le moteur de layout, jamais choisi directement dans la
|
moteur de layout, jamais choisi directement dans la bibliothèque.
|
||||||
bibliothèque.
|
|
||||||
|
|
||||||
## `ELEMENT_KIND_LABELS: dict[str, str]`
|
## `ELEMENT_KIND_LABELS: dict[str, str]`
|
||||||
Libellé d'affichage pour chaque `kind`, y compris `"row"` (pour
|
Libellé d'affichage pour chaque `kind`, y compris `"row"` (pour
|
||||||
@@ -32,12 +44,57 @@ l'affichage/debug hors bibliothèque).
|
|||||||
## `element_default_attributes(kind: str) -> dict[str, Any]`
|
## `element_default_attributes(kind: str) -> dict[str, Any]`
|
||||||
Attributs posés à la création d'un élément de ce type (voir
|
Attributs posés à la création d'un élément de ce type (voir
|
||||||
`document_engine/elements/add_document_element.py`).
|
`document_engine/elements/add_document_element.py`).
|
||||||
- **Retour** : un dict d'attributs par défaut, dépendant du `kind` :
|
- **Retour** : un dict d'attributs par défaut, dépendant du `kind` (les
|
||||||
formes (`x/y/width/height/rotation/z_index/fill/stroke/stroke_width/label`),
|
"attributs de boîte partagés" mentionnés ci-dessous — `padding/margin/
|
||||||
texte (`content/style` + `bold/italic/underline/align/color`),
|
background_color/border_radius/width/max_width/height/min_height/
|
||||||
image (`src/alt`), bouton (`label/target`), rangée (`gap/align/justify`),
|
max_height/min_width/box_shadow/opacity/align_self/content_align/
|
||||||
quiz (`DEFAULT_QUIZ_CONFIG`, voir `quiz_config.py`), autre mini-jeu
|
border` — sont toujours les mêmes, voir `rendering/box_style.py` :
|
||||||
(`theme_color`), ou `{}` pour un `kind` inconnu.
|
tous vides, `False` ou `"none"`/`"stretch"`/`"top"` par défaut =
|
||||||
|
comportement historique inchangé pour le kind qui les gagne ;
|
||||||
|
`content_align` — alignement vertical du CONTENU dans son bloc, retour
|
||||||
|
utilisateur du 26/09/2026 — n'est cependant appliqué au rendu QUE par
|
||||||
|
les kinds dont le conteneur est en colonne (texte, liste, figure d'une
|
||||||
|
image légendée), jamais par le Bouton, voir `rendering.md`) :
|
||||||
|
texte (`content/style` + `bold/italic/underline/strikethrough/align/
|
||||||
|
color/font_family/font_size/line_height/letter_spacing/
|
||||||
|
text_transform/text_shadow` + les attributs de boîte partagés),
|
||||||
|
image (`src/alt/svg_markup` — `svg_markup` prend le pas sur `src` au
|
||||||
|
rendu, voir rendering.md — `object_fit="cover"` et `height="220px"`
|
||||||
|
par défaut (retour utilisateur du 26/09/2026 : "ce cadre ne devrait
|
||||||
|
pas changer de taille en fonction de la taille de l'image mais être
|
||||||
|
fixe et contraindre l'image dedans" — un cadre FIXE, jamais dicté par
|
||||||
|
la résolution native du fichier importé ; "Taille réelle" reste un
|
||||||
|
choix explicite possible via le panneau), `aspect_ratio/filter_preset`
|
||||||
|
vides par défaut, `click_behavior` (`""`/`"link"`/`"lightbox"`) +
|
||||||
|
`link_url` + `lazy_load` + `caption` : comportement/contenu, pas du
|
||||||
|
style + les attributs de boîte partagés), bouton (`label/target/
|
||||||
|
attachment_stored_name/attachment_filename` — la pièce jointe est
|
||||||
|
indépendante de `target`, réservé à la navigation — + `bold/italic/
|
||||||
|
text_transform/font_family/font_size/letter_spacing/text_color`
|
||||||
|
(typo, jamais gérés par box_style.py) + `svg_markup/icon_position
|
||||||
|
("before"/"after")/icon_size` (icône optionnelle, voir rendering.md)
|
||||||
|
+ les attributs de boîte partagés), liste à
|
||||||
|
puces/numérotée (`items`, une liste de chaînes — style sur la LISTE
|
||||||
|
ENTIÈRE uniquement, jamais par élément individuel ni de sous-listes,
|
||||||
|
portée actée avec l'utilisateur le 26/09/2026 : `bold/italic/
|
||||||
|
underline/font_family/font_size/line_height/text_color` (typo) +
|
||||||
|
`list_style_type` (valide selon le kind, voir rendering.md)/
|
||||||
|
`list_style_position`/`marker_color`/`marker_size`/`svg_markup`
|
||||||
|
(puce personnalisée, liste à puces uniquement) + `item_padding`
|
||||||
|
(`"6px"` par défaut, PAS vide — retour utilisateur explicite : "il
|
||||||
|
faut un padding de base par élément de liste car y en a pas
|
||||||
|
aujourd'hui", UNIFORME sur tous les éléments, jamais réglable
|
||||||
|
individuellement) + `item_spacing` + les attributs de boîte
|
||||||
|
partagés), badge
|
||||||
|
(`content/svg_markup/width/border_radius/bold/uppercase` — tous vides
|
||||||
|
ou `False` par défaut = comportement historique inchangé, voir
|
||||||
|
rendering.md), 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.
|
- **Exceptions** : aucune.
|
||||||
|
|
||||||
## `quiz_config.py` — modèle de données du mini-jeu Quiz
|
## `quiz_config.py` — modèle de données du mini-jeu Quiz
|
||||||
@@ -72,3 +129,196 @@ mirroir de `db/dialogue_lines.py::sum_question_rewards` côté jeu, utile
|
|||||||
le jour où un export calculera un score maximum.
|
le jour où un export calculera un score maximum.
|
||||||
- **Retour** : entier ≥ 0.
|
- **Retour** : entier ≥ 0.
|
||||||
- **Exceptions** : aucune.
|
- **Exceptions** : aucune.
|
||||||
|
|
||||||
|
## `association_config.py` — modèle de données du mini-jeu Association
|
||||||
|
|
||||||
|
Deuxième mini-jeu implémenté : l'apprenant relie chaque carte de gauche
|
||||||
|
("terme") à sa correspondance de droite ("définition") par glisser-déposer
|
||||||
|
(voir `document_engine/rendering/render_document_element.py`::
|
||||||
|
`_render_association_player`). Même convention que `quiz_config.py`.
|
||||||
|
|
||||||
|
### `MIN_PAIRS`, `MAX_PAIRS: int`
|
||||||
|
Bornes de validation (`2`/`8` paires). `MIN_PAIRS` n'est pas imposé par
|
||||||
|
`sanitize_association_config` (une seule paire valide reste acceptée,
|
||||||
|
comme un quiz à une seule question) — c'est une recommandation pour le
|
||||||
|
panneau Propriétés, pas une contrainte technique du rendu.
|
||||||
|
|
||||||
|
### `DEFAULT_ASSOCIATION_CONFIG: dict[str, Any]`
|
||||||
|
`{"theme_color": "#ff5f2e", "pairs": []}`.
|
||||||
|
|
||||||
|
### `sanitize_association_config(raw_config: Any) -> dict[str, Any]`
|
||||||
|
Valide/nettoie une config d'association arbitraire (JSON venu du client)
|
||||||
|
— jamais ne lève, renvoie toujours un dict COMPLET fusionné sur
|
||||||
|
`DEFAULT_ASSOCIATION_CONFIG`. Chaque paire de `raw_config["pairs"]` est
|
||||||
|
validée indépendamment (voir `_sanitize_pair`, privée) : les deux côtés
|
||||||
|
(`left`/`right`) doivent être non vides une fois `.strip()`-és, sinon la
|
||||||
|
paire entière est silencieusement supprimée de la liste (jamais une
|
||||||
|
levée qui ferait échouer tout le reste du mini-jeu). La liste finale est
|
||||||
|
tronquée à `MAX_PAIRS`.
|
||||||
|
- **Retour** : dict complet (mêmes clés que `DEFAULT_ASSOCIATION_CONFIG`).
|
||||||
|
- **Exceptions** : aucune.
|
||||||
|
|
||||||
|
## `memory_config.py` — modèle de données du mini-jeu Memory
|
||||||
|
|
||||||
|
Troisième mini-jeu implémenté : l'apprenant retourne des cartes pour
|
||||||
|
constituer des paires identiques (mode `"paire"`) ou simplement révéler
|
||||||
|
chaque carte une fois (mode `"single"`, sans appariement — voir
|
||||||
|
`document_engine/rendering/render_document_element.py`::
|
||||||
|
`_render_memory_player`). Même convention que `quiz_config.py`.
|
||||||
|
|
||||||
|
### `MIN_CARDS`, `MAX_CARDS: int`
|
||||||
|
Bornes de validation (`2`/`8` cartes DÉFINIES par le créateur — en mode
|
||||||
|
`"paire"`, le plateau affiche le double, chaque carte étant dupliquée).
|
||||||
|
`MIN_CARDS` n'est pas imposé par `sanitize_memory_config` (même logique
|
||||||
|
que `MIN_PAIRS` côté Association) — recommandation pour le panneau
|
||||||
|
Propriétés, pas une contrainte technique du rendu.
|
||||||
|
|
||||||
|
### `CARD_MODES: tuple[str, ...]`
|
||||||
|
`("paire", "single")`.
|
||||||
|
|
||||||
|
### `DEFAULT_MEMORY_CONFIG: dict[str, Any]`
|
||||||
|
`{"theme_color": "#ff5f2e", "mode": "paire", "cards": []}`.
|
||||||
|
|
||||||
|
### `sanitize_memory_config(raw_config: Any) -> dict[str, Any]`
|
||||||
|
Valide/nettoie une config de memory arbitraire (JSON venu du client) —
|
||||||
|
jamais ne lève, renvoie toujours un dict COMPLET fusionné sur
|
||||||
|
`DEFAULT_MEMORY_CONFIG`. `mode` retombe sur `"paire"` s'il n'est pas dans
|
||||||
|
`CARD_MODES`. Chaque carte de `raw_config["cards"]` est validée
|
||||||
|
indépendamment (voir `_sanitize_card`/`_sanitize_card_face`, privées) :
|
||||||
|
chaque face (`recto`/`verso`) a un `image` et un `text` indépendants et
|
||||||
|
tous deux optionnels, MAIS le `verso` doit avoir au moins l'un des deux
|
||||||
|
non vide (rien à révéler/apparier sinon) — le `recto`, lui, peut rester
|
||||||
|
entièrement vide (dos de carte générique "?" par défaut côté rendu). Une
|
||||||
|
carte invalide est silencieusement supprimée de la liste. La liste finale
|
||||||
|
est tronquée à `MAX_CARDS`.
|
||||||
|
- **Retour** : dict complet (mêmes clés que `DEFAULT_MEMORY_CONFIG`).
|
||||||
|
- **Exceptions** : aucune.
|
||||||
|
|
||||||
|
## `mots_config.py` — modèle de données du mini-jeu Mots mêlés
|
||||||
|
|
||||||
|
Quatrième mini-jeu implémenté : l'apprenant retrouve chaque mot caché
|
||||||
|
dans une grille de lettres (horizontalement, verticalement, ou en
|
||||||
|
diagonale — voir `document_engine/rendering/render_document_element.py`::
|
||||||
|
`_render_mots_player`, qui construit la grille elle-même). Même
|
||||||
|
convention que `quiz_config.py`.
|
||||||
|
|
||||||
|
### `MIN_WORDS`, `MAX_WORDS: int`
|
||||||
|
Bornes de validation (`5`/`10` mots). `MIN_WORDS` n'est pas imposé par
|
||||||
|
`sanitize_mots_config` (même logique que `MIN_PAIRS`/`MIN_CARDS`
|
||||||
|
côté Association/Memory) — recommandation pour le panneau Propriétés,
|
||||||
|
pas une contrainte technique du rendu : un seul mot valide reste
|
||||||
|
accepté, la grille se construit quand même autour de lui.
|
||||||
|
|
||||||
|
### `MIN_WORD_LENGTH`, `MAX_WORD_LENGTH: int`
|
||||||
|
Bornes de longueur d'un mot individuel une fois nettoyé (`2`/`20`
|
||||||
|
lettres) — un mot trop court n'a pas de sens à chercher, un mot trop
|
||||||
|
long compliquerait inutilement le calcul de la taille de la grille
|
||||||
|
(voir `_mots_grid_size_for_words` dans `render_document_element.py`).
|
||||||
|
|
||||||
|
### `DEFAULT_MOTS_CONFIG: dict[str, Any]`
|
||||||
|
`{"theme_color": "#ff5f2e", "words": []}`.
|
||||||
|
|
||||||
|
### `sanitize_mots_config(raw_config: Any) -> dict[str, Any]`
|
||||||
|
Valide/nettoie une config de mots mêlés arbitraire (JSON venu du
|
||||||
|
client) — jamais ne lève, renvoie toujours un dict COMPLET fusionné sur
|
||||||
|
`DEFAULT_MOTS_CONFIG`. Chaque mot de `raw_config["words"]` est nettoyé
|
||||||
|
indépendamment (voir `_sanitize_word`, privée) : mis en MAJUSCULES,
|
||||||
|
les accents sont retirés (décomposition NFKD + filtrage ASCII, pour que
|
||||||
|
deux mots qui se croisent sur une même case de la grille puissent
|
||||||
|
partager exactement la même lettre), seules les lettres sont
|
||||||
|
conservées. Un mot qui ne contient plus rien d'exploitable (ou moins de
|
||||||
|
`MIN_WORD_LENGTH` lettres) une fois nettoyé est silencieusement
|
||||||
|
supprimé de la liste, de même qu'un doublon exact d'un mot déjà retenu
|
||||||
|
(un mot répété deux fois n'aurait rien de plus à faire trouver). La
|
||||||
|
liste finale est tronquée à `MAX_WORDS`.
|
||||||
|
- **Retour** : dict complet (mêmes clés que `DEFAULT_MOTS_CONFIG`).
|
||||||
|
- **Exceptions** : aucune.
|
||||||
|
|
||||||
|
## `scenario_config.py` — modèle de données du mini-jeu Scénario
|
||||||
|
|
||||||
|
Cinquième mini-jeu implémenté : l'apprenant lit une situation initiale,
|
||||||
|
choisit une option, puis découvre la conséquence de SON choix — cette
|
||||||
|
conséquence peut elle-même mener à de nouveaux choix, et ainsi de suite
|
||||||
|
(arbre de décision, voir `document_engine/rendering/
|
||||||
|
render_document_element.py`::`_render_scenario_player`). Aucune notion
|
||||||
|
de bonne/mauvaise réponse (retour utilisateur du 20/09/2026 : "il n'y a
|
||||||
|
pas de notion vrai/faux, l'utilisateur observe les conséquences") —
|
||||||
|
l'apprenant explore simplement les branches jusqu'à une fin. Le
|
||||||
|
créateur peut définir plusieurs scénarios (plusieurs arbres
|
||||||
|
indépendants), joués les uns après les autres dans l'ORDRE d'écriture
|
||||||
|
(jamais mélangés, contrairement à Association/Memory/Mots mêlés). Même
|
||||||
|
convention resolve_X/sanitize_X que `quiz_config.py`.
|
||||||
|
|
||||||
|
**Structure d'un scénario** : `{"title": str, "nodes": [...]}`.
|
||||||
|
`nodes[0]` est TOUJOURS la situation initiale (racine de l'arbre, jamais
|
||||||
|
supprimable depuis le panneau Propriétés — voir
|
||||||
|
`forgeDocScenarioGraphRenderInspector`, static/document/js/
|
||||||
|
document-editor.js). Chaque nœud est `{"id": str, "text": str,
|
||||||
|
"x": float, "y": float, "choices": [...]}` — `id` est une référence
|
||||||
|
STABLE (générée côté client, jamais recalculée côté serveur) vers
|
||||||
|
laquelle un choix d'un AUTRE nœud peut pointer via son `target_id` ;
|
||||||
|
`x`/`y` positionnent le nœud sur le canevas du graphe visuel (voir
|
||||||
|
`forgeDocRenderScenarioTreeModal`) ; un nœud SANS choix est une fin de
|
||||||
|
branche (état normal, pas une erreur — contrairement à l'ancien modèle
|
||||||
|
qui exigeait 2 à 4 choix par scénario). Un choix est `{"text": str,
|
||||||
|
"target_id": str | None}` — `target_id` à `None` signifie "fin de
|
||||||
|
branche à ce choix".
|
||||||
|
|
||||||
|
### `MAX_SCENARIO_CHOICES: int`
|
||||||
|
Borne technique du nombre de choix par nœud (`4`) — évite une UI
|
||||||
|
disproportionnée, aucun lien avec une notion de bonne/mauvaise réponse.
|
||||||
|
Nommée explicitement "SCENARIO" (pas juste `MAX_CHOICES`) pour éviter
|
||||||
|
toute collision avec la constante de même nom de `quiz_config.py`, qui
|
||||||
|
borne un concept différent (le nombre de réponses d'une question de
|
||||||
|
quiz) — `document_engine/__init__.py` ré-exporte l'intégralité de l'API
|
||||||
|
publique du paquet à plat, un nom générique entrerait en collision.
|
||||||
|
Contrairement à l'ancien modèle, il n'existe PAS de minimum : un nœud
|
||||||
|
peut avoir 0 choix (fin de branche), c'est un état valide, pas une
|
||||||
|
erreur à filtrer.
|
||||||
|
|
||||||
|
### `DEFAULT_SCENARIO_CONFIG: dict[str, Any]`
|
||||||
|
`{"theme_color": "#ff5f2e", "scenarios": []}`.
|
||||||
|
|
||||||
|
### `sanitize_scenario_config(raw_config: Any) -> dict[str, Any]`
|
||||||
|
Valide/nettoie une config de scénario arbitraire (JSON venu du client) —
|
||||||
|
jamais ne lève, renvoie toujours un dict COMPLET fusionné sur
|
||||||
|
`DEFAULT_SCENARIO_CONFIG`. Chaque scénario de `raw_config["scenarios"]`
|
||||||
|
est validé indépendamment (voir `_sanitize_scenario`/
|
||||||
|
`_sanitize_scenario_node`/`_sanitize_scenario_choice`, privées) : le
|
||||||
|
`title` doit être non vide, chaque nœud doit avoir un `id` (chaîne non
|
||||||
|
vide) et un `text` non vide une fois `.strip()`-é (sinon le nœud entier
|
||||||
|
est supprimé), et ses choix sont tronqués à `MAX_SCENARIO_CHOICES`. Un
|
||||||
|
`x`/`y` manquant ou invalide (pas un nombre, ou un booléen — `bool`
|
||||||
|
hérite de `int` en Python) retombe sur un quadrillage en cascade dérivé
|
||||||
|
de l'INDEX du nœud dans la liste (`_scenario_node_fallback_position`),
|
||||||
|
jamais `(0, 0)` pour tous les nœuds, ce qui les empilerait exactement au
|
||||||
|
même endroit sur le graphe visuel. Le scénario entier est supprimé s'il
|
||||||
|
ne reste plus aucun nœud valide après
|
||||||
|
nettoyage (il faut au moins la situation initiale). Une fois l'ensemble
|
||||||
|
des ids valides connu, tout `target_id` qui ne pointe plus vers un nœud
|
||||||
|
existant (nœud invalide/supprimé) est silencieusement remis à `None`
|
||||||
|
("fin de branche") plutôt que de faire échouer tout le scénario — même
|
||||||
|
philosophie que le reste de ce module : ne jamais faire échouer une
|
||||||
|
structure entière pour une seule référence cassée.
|
||||||
|
- **Retour** : dict complet (mêmes clés que `DEFAULT_SCENARIO_CONFIG`).
|
||||||
|
- **Exceptions** : aucune.
|
||||||
|
|
||||||
|
## `sanitize_element_attributes.py` — point d'entrée unique de revalidation par kind
|
||||||
|
|
||||||
|
Dispatch `kind -> sanitize_X_config` (`_SANITIZERS`, privée) pour les 5
|
||||||
|
mini-jeux à structure garantie (Quiz/Association/Memory/Mots
|
||||||
|
mêlés/Scénario) — les autres kinds n'ont pas de sanitizer et sont
|
||||||
|
renvoyés tels quels. Utilisé à la fois à l'ÉCRITURE
|
||||||
|
(`routes/document/document_element_update.py`) ET à la LECTURE
|
||||||
|
(`routes/document/document_edit.py`, `routes/document/document_render.py`)
|
||||||
|
: sans ce second usage, des attributs stockés dans un schéma devenu
|
||||||
|
obsolète (ex. Scénario, passé d'une liste plate à un arbre de décision)
|
||||||
|
atteindraient le panneau Propriétés côté client TELS QUELS, qui suppose
|
||||||
|
la forme ACTUELLE — bug réel constaté le 21/09/2026, un élément Scénario
|
||||||
|
créé avant la refonte en arbre faisait planter silencieusement le
|
||||||
|
panneau Propriétés (`scenario.nodes` inexistant sur l'ancienne forme).
|
||||||
|
|
||||||
|
### `sanitize_element_attributes(kind: str, attributes: Any) -> Any`
|
||||||
|
- **Retour** : `attributes` revalidé si `kind` a un sanitizer, sinon
|
||||||
|
`attributes` tel quel.
|
||||||
|
- **Exceptions** : aucune (délègue à des sanitizers qui ne lèvent jamais).
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"""Modèle de données du mini-jeu Memory (voir docs/plan/PLAN.md §3.2) —
|
||||||
|
l'apprenant retourne des cartes pour constituer des paires identiques
|
||||||
|
(mode "paire") ou simplement révéler chaque carte une fois (mode
|
||||||
|
"single", un retournement classique sans appariement). Même convention
|
||||||
|
resolve_X/sanitize_X que quiz_config.py/association_config.py (aucun
|
||||||
|
import croisé)."""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
MIN_CARDS = 2
|
||||||
|
MAX_CARDS = 8
|
||||||
|
CARD_MODES = ("paire", "single")
|
||||||
|
DEFAULT_MODE = "paire"
|
||||||
|
|
||||||
|
DEFAULT_MEMORY_CONFIG: dict[str, Any] = {
|
||||||
|
"theme_color": "#ff5f2e",
|
||||||
|
"mode": DEFAULT_MODE,
|
||||||
|
"cards": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_card_face(raw: Any) -> dict[str, str]:
|
||||||
|
"""Une face de carte (recto ou verso) — image et texte tous deux
|
||||||
|
optionnels et indépendants (le créateur peut mettre l'un, l'autre, ou
|
||||||
|
les deux, voir docs/plan/PLAN.md)."""
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
return {"image": "", "text": ""}
|
||||||
|
return {
|
||||||
|
"image": str(raw.get("image", "")).strip(),
|
||||||
|
"text": str(raw.get("text", "")).strip(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_card(raw: Any) -> dict[str, Any] | None:
|
||||||
|
"""None si la carte est invalide — un verso entièrement vide (ni image
|
||||||
|
ni texte) n'aurait rien à révéler/apparier, contrairement au recto qui
|
||||||
|
peut légitimement rester vide (dos de carte générique par défaut)."""
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
return None
|
||||||
|
recto = _sanitize_card_face(raw.get("recto"))
|
||||||
|
verso = _sanitize_card_face(raw.get("verso"))
|
||||||
|
if not verso["image"] and not verso["text"]:
|
||||||
|
return None
|
||||||
|
return {"recto": recto, "verso": verso}
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_memory_config(raw_config: Any) -> dict[str, Any]:
|
||||||
|
config = dict(DEFAULT_MEMORY_CONFIG)
|
||||||
|
if not isinstance(raw_config, dict):
|
||||||
|
return config
|
||||||
|
theme_color = raw_config.get("theme_color")
|
||||||
|
if isinstance(theme_color, str) and theme_color:
|
||||||
|
config["theme_color"] = theme_color
|
||||||
|
mode = raw_config.get("mode")
|
||||||
|
config["mode"] = mode if mode in CARD_MODES else DEFAULT_MODE
|
||||||
|
raw_cards = raw_config.get("cards")
|
||||||
|
if isinstance(raw_cards, list):
|
||||||
|
cards = [c for c in (_sanitize_card(item) for item in raw_cards) if c is not None]
|
||||||
|
config["cards"] = cards[:MAX_CARDS]
|
||||||
|
return config
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"""Modèle de données du mini-jeu Mots mêlés (voir docs/plan/PLAN.md §3.2)
|
||||||
|
— l'apprenant retrouve chaque mot caché dans une grille de lettres, placé
|
||||||
|
horizontalement, verticalement, ou en diagonale (haut-gauche vers
|
||||||
|
bas-droite, ou haut-droite vers bas-gauche), jamais à l'envers ni en
|
||||||
|
diagonale inversée (voir document_engine/rendering/render_document_element.py
|
||||||
|
::_render_mots_player, qui construit la grille elle-même). Même
|
||||||
|
convention resolve_X/sanitize_X que quiz_config.py/association_config.py/
|
||||||
|
memory_config.py (aucun import croisé)."""
|
||||||
|
|
||||||
|
import unicodedata
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
MIN_WORDS = 5
|
||||||
|
MAX_WORDS = 10
|
||||||
|
MIN_WORD_LENGTH = 2
|
||||||
|
MAX_WORD_LENGTH = 20
|
||||||
|
|
||||||
|
DEFAULT_MOTS_CONFIG: dict[str, Any] = {
|
||||||
|
"theme_color": "#ff5f2e",
|
||||||
|
"words": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_word(raw: Any) -> str | None:
|
||||||
|
"""None si, une fois nettoyé, il ne reste aucune lettre exploitable —
|
||||||
|
filtré par sanitize_mots_config plutôt que de faire échouer toute la
|
||||||
|
grille, même convention que quiz_config.py::_sanitize_question. Les
|
||||||
|
accents sont retirés (décomposition NFKD puis filtrage ASCII) : deux
|
||||||
|
mots qui se croisent sur une même case doivent pouvoir partager
|
||||||
|
exactement la même lettre, ce qu'un "É" et un "E" ne permettraient
|
||||||
|
pas."""
|
||||||
|
if not isinstance(raw, str):
|
||||||
|
return None
|
||||||
|
decomposed = unicodedata.normalize("NFKD", raw.upper())
|
||||||
|
letters = "".join(ch for ch in decomposed if ch.isalpha() and ch.isascii())
|
||||||
|
if len(letters) < MIN_WORD_LENGTH:
|
||||||
|
return None
|
||||||
|
return letters[:MAX_WORD_LENGTH]
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_mots_config(raw_config: Any) -> dict[str, Any]:
|
||||||
|
config = dict(DEFAULT_MOTS_CONFIG)
|
||||||
|
if not isinstance(raw_config, dict):
|
||||||
|
return config
|
||||||
|
theme_color = raw_config.get("theme_color")
|
||||||
|
if isinstance(theme_color, str) and theme_color:
|
||||||
|
config["theme_color"] = theme_color
|
||||||
|
raw_words = raw_config.get("words")
|
||||||
|
if isinstance(raw_words, list):
|
||||||
|
words: list[str] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for item in raw_words:
|
||||||
|
word = _sanitize_word(item)
|
||||||
|
if word is None or word in seen:
|
||||||
|
continue
|
||||||
|
seen.add(word)
|
||||||
|
words.append(word)
|
||||||
|
config["words"] = words[:MAX_WORDS]
|
||||||
|
return config
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"""Point d'entrée UNIQUE de revalidation des attributs d'un élément par
|
||||||
|
kind — utilisé à la fois à l'ÉCRITURE (routes/document/
|
||||||
|
document_element_update.py) ET à la LECTURE (routes/document/
|
||||||
|
document_edit.py, routes/document/document_render.py). Sans ce second
|
||||||
|
usage en lecture, des attributs stockés dans un schéma devenu obsolète
|
||||||
|
(ex. le mini-jeu Scénario, passé d'une liste plate à un arbre de
|
||||||
|
décision) atteindraient le panneau Propriétés côté client TELS QUELS,
|
||||||
|
qui suppose la forme ACTUELLE — bug réel constaté le 21/09/2026 : un
|
||||||
|
élément Scénario créé avant la refonte en arbre faisait planter
|
||||||
|
silencieusement le panneau Propriétés (`scenario.nodes` inexistant sur
|
||||||
|
l'ancienne forme), sans qu'aucun message n'indique pourquoi."""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from .association_config import sanitize_association_config
|
||||||
|
from .memory_config import sanitize_memory_config
|
||||||
|
from .mots_config import sanitize_mots_config
|
||||||
|
from .quiz_config import sanitize_quiz_config
|
||||||
|
from .scenario_config import sanitize_scenario_config
|
||||||
|
|
||||||
|
_SANITIZERS = {
|
||||||
|
"quiz": sanitize_quiz_config,
|
||||||
|
"association": sanitize_association_config,
|
||||||
|
"memory": sanitize_memory_config,
|
||||||
|
"mots": sanitize_mots_config,
|
||||||
|
"scenario": sanitize_scenario_config,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_element_attributes(kind: str, attributes: Any) -> Any:
|
||||||
|
"""Revalide `attributes` selon `kind`, UNIQUEMENT pour les kinds à
|
||||||
|
structure garantie (les 5 mini-jeux ci-dessus, voir `_SANITIZERS`) —
|
||||||
|
les autres kinds (texte/forme/image/bouton/rangée) sont de simples
|
||||||
|
valeurs scalaires sans structure à garantir, renvoyés TELS QUELS
|
||||||
|
(jamais sanitizés : ce ne sont pas leur responsabilité ici)."""
|
||||||
|
sanitizer = _SANITIZERS.get(kind)
|
||||||
|
return sanitizer(attributes) if sanitizer else attributes
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
"""Modèle de données du mini-jeu Scénario (voir docs/plan/PLAN.md §3.2) —
|
||||||
|
l'apprenant lit une situation initiale, choisit une option, et découvre
|
||||||
|
la conséquence de SON choix ; cette conséquence peut elle-même mener à
|
||||||
|
de nouveaux choix, et ainsi de suite (arbre de décision, voir
|
||||||
|
document_engine/rendering/render_document_element.py::
|
||||||
|
_render_scenario_player). Aucune notion de bonne/mauvaise réponse
|
||||||
|
(retour utilisateur du 20/09/2026 : "il n'y a pas de notion vrai/faux,
|
||||||
|
l'utilisateur observe les conséquences") — l'apprenant explore
|
||||||
|
simplement les branches. Le créateur peut définir plusieurs scénarios
|
||||||
|
(plusieurs arbres indépendants), joués les uns après les autres dans
|
||||||
|
l'ordre d'écriture. Même convention resolve_X/sanitize_X que
|
||||||
|
quiz_config.py/association_config.py/memory_config.py/mots_config.py
|
||||||
|
(aucun import croisé) : sanitize_scenario_config est pure, ne lève
|
||||||
|
jamais, et renvoie toujours un dict complet.
|
||||||
|
|
||||||
|
Structure d'un scénario : {"title": str, "nodes": [...]}. `nodes[0]` est
|
||||||
|
TOUJOURS la situation initiale (racine de l'arbre, jamais supprimable
|
||||||
|
depuis le panneau Propriétés). Chaque nœud est
|
||||||
|
{"id": str, "text": str, "x": float, "y": float, "choices": [...]} —
|
||||||
|
`id` est une référence STABLE (générée côté client, jamais recalculée
|
||||||
|
ici) vers laquelle un choix d'un AUTRE nœud peut pointer via son
|
||||||
|
`target_id` ; un nœud sans choix est une fin de branche. `x`/`y`
|
||||||
|
positionnent le nœud sur le canevas du graphe visuel (voir
|
||||||
|
document_engine/rendering/render_document_element.py::
|
||||||
|
_render_scenario_player et forgeDocRenderScenarioGraph, static/document/
|
||||||
|
js/document-editor.js) — une position manquante/invalide retombe sur un
|
||||||
|
quadrillage en cascade calculé depuis l'INDEX du nœud dans la liste
|
||||||
|
(_scenario_node_fallback_position), jamais (0, 0) pour tous les nœuds,
|
||||||
|
ce qui les empilerait exactement au même endroit. Un choix est
|
||||||
|
{"text": str, "target_id": str | None} — `target_id` à None signifie
|
||||||
|
"fin de branche à ce choix" (aucun nœud suivant)."""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
MAX_SCENARIO_CHOICES = 4
|
||||||
|
_NODE_GRID_COLUMNS = 4
|
||||||
|
_NODE_GRID_STEP_X = 220
|
||||||
|
_NODE_GRID_STEP_Y = 160
|
||||||
|
_NODE_GRID_MARGIN = 40
|
||||||
|
|
||||||
|
DEFAULT_SCENARIO_CONFIG: dict[str, Any] = {
|
||||||
|
"theme_color": "#ff5f2e",
|
||||||
|
"scenarios": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_scenario_choice(raw: Any) -> dict[str, Any] | None:
|
||||||
|
"""None si le choix est invalide (texte vide) — filtrée par
|
||||||
|
_sanitize_scenario_node plutôt que de faire échouer tout le nœud,
|
||||||
|
même convention que quiz_config.py::_sanitize_question. `target_id`
|
||||||
|
est vérifié/nettoyé une seconde fois par _sanitize_scenario (une fois
|
||||||
|
l'ensemble des ids valides du scénario connu), jamais ici."""
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
return None
|
||||||
|
text = str(raw.get("text", "")).strip()
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
target_id = raw.get("target_id")
|
||||||
|
return {"text": text, "target_id": target_id if isinstance(target_id, str) and target_id else None}
|
||||||
|
|
||||||
|
|
||||||
|
def _scenario_node_fallback_position(index: int) -> tuple[float, float]:
|
||||||
|
"""Position par défaut d'un nœud dont x/y est manquant/invalide — un
|
||||||
|
quadrillage en cascade dérivé de son INDEX dans la liste, jamais
|
||||||
|
(0, 0) pour tous les nœuds (qui les empilerait exactement au même
|
||||||
|
endroit, rendant le graphe visuel illisible à la première ouverture
|
||||||
|
d'un scénario créé avant l'ajout de x/y au modèle, ou d'un nœud
|
||||||
|
ajouté par un client qui n'enverrait pas encore de position)."""
|
||||||
|
col = index % _NODE_GRID_COLUMNS
|
||||||
|
row = index // _NODE_GRID_COLUMNS
|
||||||
|
return (
|
||||||
|
_NODE_GRID_MARGIN + col * _NODE_GRID_STEP_X,
|
||||||
|
_NODE_GRID_MARGIN + row * _NODE_GRID_STEP_Y,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_scenario_node(raw: Any, index: int) -> dict[str, Any] | None:
|
||||||
|
"""None si le nœud est invalide (id absent, ou texte vide) — même
|
||||||
|
convention que les autres sanitize_X de ce module. Un nœud SANS choix
|
||||||
|
est parfaitement valide (fin de branche), contrairement à l'ancien
|
||||||
|
modèle où un scénario exigeait 2 à 4 choix : ici, 0 choix est un état
|
||||||
|
normal, pas une erreur."""
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
return None
|
||||||
|
node_id = raw.get("id")
|
||||||
|
if not isinstance(node_id, str) or not node_id:
|
||||||
|
return None
|
||||||
|
text = str(raw.get("text", "")).strip()
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
raw_choices = raw.get("choices")
|
||||||
|
choices = []
|
||||||
|
if isinstance(raw_choices, list):
|
||||||
|
choices = [c for c in (_sanitize_scenario_choice(item) for item in raw_choices) if c is not None]
|
||||||
|
choices = choices[:MAX_SCENARIO_CHOICES]
|
||||||
|
fallback_x, fallback_y = _scenario_node_fallback_position(index)
|
||||||
|
raw_x, raw_y = raw.get("x"), raw.get("y")
|
||||||
|
x = raw_x if isinstance(raw_x, (int, float)) and not isinstance(raw_x, bool) else fallback_x
|
||||||
|
y = raw_y if isinstance(raw_y, (int, float)) and not isinstance(raw_y, bool) else fallback_y
|
||||||
|
return {"id": node_id, "text": text, "x": x, "y": y, "choices": choices}
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_scenario(raw: Any) -> dict[str, Any] | None:
|
||||||
|
"""None si le scénario est invalide (titre vide, ou aucun nœud
|
||||||
|
valide restant après nettoyage — il faut au moins la situation
|
||||||
|
initiale). Un choix qui pointait vers un nœud devenu invalide/
|
||||||
|
supprimé dégrade silencieusement vers `target_id: None` ("fin de
|
||||||
|
branche") plutôt que de faire échouer tout le scénario — même
|
||||||
|
philosophie que le reste de ce module : ne jamais lever, ne jamais
|
||||||
|
faire échouer une structure entière pour une seule référence
|
||||||
|
cassée."""
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
return None
|
||||||
|
title = str(raw.get("title", "")).strip()
|
||||||
|
if not title:
|
||||||
|
return None
|
||||||
|
raw_nodes = raw.get("nodes")
|
||||||
|
if not isinstance(raw_nodes, list):
|
||||||
|
return None
|
||||||
|
nodes: list[dict[str, Any]] = []
|
||||||
|
seen_ids: set[str] = set()
|
||||||
|
for item in raw_nodes:
|
||||||
|
node = _sanitize_scenario_node(item, len(nodes))
|
||||||
|
if node is None or node["id"] in seen_ids:
|
||||||
|
continue
|
||||||
|
seen_ids.add(node["id"])
|
||||||
|
nodes.append(node)
|
||||||
|
if not nodes:
|
||||||
|
return None
|
||||||
|
valid_ids = {n["id"] for n in nodes}
|
||||||
|
for node in nodes:
|
||||||
|
node["choices"] = [
|
||||||
|
{**c, "target_id": c["target_id"] if c["target_id"] in valid_ids else None} for c in node["choices"]
|
||||||
|
]
|
||||||
|
return {"title": title, "nodes": nodes}
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_scenario_config(raw_config: Any) -> dict[str, Any]:
|
||||||
|
config = dict(DEFAULT_SCENARIO_CONFIG)
|
||||||
|
if not isinstance(raw_config, dict):
|
||||||
|
return config
|
||||||
|
theme_color = raw_config.get("theme_color")
|
||||||
|
if isinstance(theme_color, str) and theme_color:
|
||||||
|
config["theme_color"] = theme_color
|
||||||
|
raw_scenarios = raw_config.get("scenarios")
|
||||||
|
if isinstance(raw_scenarios, list):
|
||||||
|
config["scenarios"] = [s for s in (_sanitize_scenario(item) for item in raw_scenarios) if s is not None]
|
||||||
|
return config
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
from db.supports import connect_support, ensure_document_pages_schema
|
||||||
|
|
||||||
|
|
||||||
|
def add_document_page(slug: str, title: str | None = None) -> int:
|
||||||
|
"""Ajoute une page en fin de la bande d'onglets — titre par défaut
|
||||||
|
"Page N" (N = position 1-indexée dans la liste actuelle + 1) si
|
||||||
|
`title` n'est pas fourni, jamais un titre vide (voir
|
||||||
|
update_document_page.py, qui lui rejette silencieusement un titre
|
||||||
|
vide au renommage — ici la valeur par défaut ne peut structurellement
|
||||||
|
pas être vide, donc rien à valider)."""
|
||||||
|
ensure_document_pages_schema(slug)
|
||||||
|
conn = connect_support(slug)
|
||||||
|
max_row = conn.execute("SELECT MAX(order_index) AS m, COUNT(*) AS n FROM _document_pages").fetchone()
|
||||||
|
order_index = (max_row["m"] or 0) + 1 if max_row["n"] else 0
|
||||||
|
page_title = title.strip() if title and title.strip() else f"Page {max_row['n'] + 1}"
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO _document_pages (title, order_index) VALUES (?, ?)",
|
||||||
|
(page_title, order_index),
|
||||||
|
)
|
||||||
|
page_id = int(conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"])
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
return page_id
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
from db.supports import connect_support, ensure_document_pages_schema
|
||||||
|
|
||||||
|
|
||||||
|
def delete_all_document_pages(slug: str) -> None:
|
||||||
|
"""Supprime toutes les pages d'un support d'un coup (retour
|
||||||
|
utilisateur : "une option dans page pour supprimer toute les page
|
||||||
|
d'un coup") — CASCADE (contrainte FK, voir create_support.py) retire
|
||||||
|
aussi tous les éléments de contenu du support. Résultat : un support
|
||||||
|
à 0 page, état volontairement valide (voir list_document_pages.py) ;
|
||||||
|
l'utilisateur repart d'un éditeur vide comme un support neuf."""
|
||||||
|
ensure_document_pages_schema(slug)
|
||||||
|
conn = connect_support(slug)
|
||||||
|
conn.execute("DELETE FROM _document_elements")
|
||||||
|
conn.execute("DELETE FROM _document_pages")
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
from db.supports import connect_support, ensure_document_pages_schema
|
||||||
|
|
||||||
|
|
||||||
|
def delete_document_page(slug: str, page_id: int) -> None:
|
||||||
|
"""Supprime une page — CASCADE (contrainte FK sur les supports créés
|
||||||
|
après l'ajout des pages ; ensure_document_pages_schema n'en pose pas
|
||||||
|
pour les anciens, voir son commentaire) retire aussi ses éléments.
|
||||||
|
Ne refuse JAMAIS ici de supprimer la dernière page restante — cette
|
||||||
|
règle est un garde-fou métier posé par l'appelant (voir
|
||||||
|
routes/document/document_page_delete.py), pas une contrainte
|
||||||
|
structurelle de cette fonction bas niveau (même découpage que
|
||||||
|
routes/game/screens/screen_delete.py côté jeu)."""
|
||||||
|
ensure_document_pages_schema(slug)
|
||||||
|
conn = connect_support(slug)
|
||||||
|
conn.execute("DELETE FROM _document_elements WHERE page_id = ?", (page_id,))
|
||||||
|
conn.execute("DELETE FROM _document_pages WHERE id = ?", (page_id,))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from db.supports import connect_support, ensure_document_pages_schema
|
||||||
|
|
||||||
|
|
||||||
|
def get_document_page(slug: str, page_id: int) -> dict[str, Any] | None:
|
||||||
|
ensure_document_pages_schema(slug)
|
||||||
|
conn = connect_support(slug)
|
||||||
|
row = conn.execute("SELECT * FROM _document_pages WHERE id = ?", (page_id,)).fetchone()
|
||||||
|
conn.close()
|
||||||
|
return dict(row) if row else None
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from db.supports import connect_support, ensure_document_pages_schema
|
||||||
|
|
||||||
|
|
||||||
|
def list_document_pages(slug: str) -> list[dict[str, Any]]:
|
||||||
|
"""Toutes les pages d'un support, triées par order_index — la bande
|
||||||
|
d'onglets du panneau Propriétés (voir static/document/js/
|
||||||
|
document-editor.js) et le sélecteur de page du Mode Aperçu en dérivent
|
||||||
|
directement. Peut renvoyer une liste VIDE (retour utilisateur : un
|
||||||
|
support neuf, ou vidé via "Supprimer toutes les pages", s'ouvre sans
|
||||||
|
aucune page — voir routes/document/document_edit.py, qui gère ce cas)."""
|
||||||
|
ensure_document_pages_schema(slug)
|
||||||
|
conn = connect_support(slug)
|
||||||
|
rows = conn.execute("SELECT * FROM _document_pages ORDER BY order_index").fetchall()
|
||||||
|
conn.close()
|
||||||
|
return [dict(row) for row in rows]
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
from db.supports import connect_support, ensure_document_pages_schema
|
||||||
|
|
||||||
|
|
||||||
|
def move_document_page(slug: str, page_id: int, new_index: int) -> None:
|
||||||
|
"""Réordonne une page dans la bande d'onglets (glisser-déposer) —
|
||||||
|
renumérote intégralement order_index sur TOUTES les pages, même
|
||||||
|
principe que move_document_element (jamais un décalage un par un)."""
|
||||||
|
ensure_document_pages_schema(slug)
|
||||||
|
conn = connect_support(slug)
|
||||||
|
page_ids = [
|
||||||
|
r["id"]
|
||||||
|
for r in conn.execute(
|
||||||
|
"SELECT id FROM _document_pages WHERE id != ? ORDER BY order_index", (page_id,)
|
||||||
|
).fetchall()
|
||||||
|
]
|
||||||
|
page_ids.insert(max(0, min(new_index, len(page_ids))), page_id)
|
||||||
|
for index, pid in enumerate(page_ids):
|
||||||
|
conn.execute("UPDATE _document_pages SET order_index = ? WHERE id = ?", (index, pid))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
# document_engine/pages/
|
||||||
|
|
||||||
|
CRUD des pages d'un support de formation (`_document_pages`, voir
|
||||||
|
`db/supports/create_support.py`) — retour utilisateur du 21/09/2026:
|
||||||
|
"il faut implémenter un système de page". Un support est désormais
|
||||||
|
composé de plusieurs pages, chacune portant son propre flux d'éléments
|
||||||
|
(voir `document_engine/elements/`, filtré par `page_id`).
|
||||||
|
|
||||||
|
**Un support peut avoir 0 page** (retour utilisateur du 26/09/2026 :
|
||||||
|
"l'éditeur ne dois plus etre obliger d'avoir une page active ou créer,
|
||||||
|
il peut etre ouvert sans aucune page") — `create_support` n'en crée plus
|
||||||
|
aucune par défaut, et `ensure_document_pages_schema` ne recrée plus
|
||||||
|
"Page 1" dès que la table est vide (seule exception : la migration
|
||||||
|
ponctuelle et historique d'un support pré-pages qui avait déjà des
|
||||||
|
éléments sans `page_id`). `routes/document/document_edit.py` et le
|
||||||
|
frontend (`static/document/js/document-editor.js`) gèrent explicitement
|
||||||
|
cet état "aucune page" (pas de page active, canevas vide avec une
|
||||||
|
invite à en créer une). La garde "jamais supprimer la dernière page" a
|
||||||
|
été retirée du côté route (voir `delete_all_document_pages` ci-dessous
|
||||||
|
et `routes/document/document_page_delete.py`) : ce paquet n'a jamais
|
||||||
|
posé cette contrainte lui-même.
|
||||||
|
|
||||||
|
## `add_document_page(slug: str, title: str | None = None) -> int`
|
||||||
|
Ajoute une page en fin de la bande d'onglets. `title` par défaut :
|
||||||
|
"Page N" (N = position 1-indexée + 1) si non fourni.
|
||||||
|
- **Retour** : l'`id` de la nouvelle page.
|
||||||
|
- **Exceptions** : aucune levée explicitement.
|
||||||
|
|
||||||
|
## `list_document_pages(slug: str) -> list[dict[str, Any]]`
|
||||||
|
Toutes les pages du support, triées par `order_index`.
|
||||||
|
- **Retour** : liste de dicts (une ligne de table chacun).
|
||||||
|
- **Exceptions** : aucune.
|
||||||
|
|
||||||
|
## `get_document_page(slug: str, page_id: int) -> dict[str, Any] | None`
|
||||||
|
Récupère une seule page par id.
|
||||||
|
- **Retour** : le dict de la page, ou `None` si l'id n'existe pas.
|
||||||
|
- **Exceptions** : aucune.
|
||||||
|
|
||||||
|
## `update_document_page(slug: str, page_id: int, title: str) -> None`
|
||||||
|
Renomme une page. Un titre vide (une fois `.strip()`-é) est
|
||||||
|
silencieusement ignoré — la page garde son titre précédent plutôt que de
|
||||||
|
se retrouver sans nom dans la bande d'onglets.
|
||||||
|
- **Retour** : aucun.
|
||||||
|
- **Exceptions** : aucune.
|
||||||
|
|
||||||
|
## `move_document_page(slug: str, page_id: int, new_index: int) -> None`
|
||||||
|
Réordonne une page dans la bande d'onglets (glisser-déposer) —
|
||||||
|
renumérote intégralement `order_index` sur toutes les pages, même
|
||||||
|
principe que `document_engine.move_document_element` (jamais un
|
||||||
|
décalage un par un).
|
||||||
|
- **Retour** : aucun.
|
||||||
|
- **Exceptions** : aucune.
|
||||||
|
|
||||||
|
## `set_document_page_vertical_align(slug: str, page_id: int, vertical_align: str) -> None`
|
||||||
|
Règle l'alignement vertical du CONTENU d'une page (`justify-content` de
|
||||||
|
`.docPageContent`, voir `static/document/document-editor.css`) —
|
||||||
|
réglable depuis le panneau Propriétés quand l'onglet "Pages" de
|
||||||
|
l'éditeur est actif (retour utilisateur du 24/09/2026 : "quand je suis
|
||||||
|
sur l'onglet page, dans les propriétés s'affiche l'option de
|
||||||
|
l'alignement de la page"). Une valeur hors de `VERTICAL_ALIGNS`
|
||||||
|
(`"top"`/`"center"`/`"bottom"`) retombe silencieusement sur `"top"`,
|
||||||
|
même philosophie défensive que `update_document_page` pour un titre
|
||||||
|
vide.
|
||||||
|
- **Retour** : aucun.
|
||||||
|
- **Exceptions** : aucune.
|
||||||
|
|
||||||
|
### `VERTICAL_ALIGNS: tuple[str, ...]`
|
||||||
|
`("top", "center", "bottom")` — valeurs valides de `vertical_align`,
|
||||||
|
`"top"` étant la valeur par défaut posée en base (voir
|
||||||
|
`db/supports/create_support.py`/`ensure_document_pages_schema.py`).
|
||||||
|
|
||||||
|
## `replace_document_content(slug: str, seed_pages: list[dict[str, Any]]) -> None`
|
||||||
|
Remplace TOUT le contenu du support par `seed_pages` — utilisée
|
||||||
|
UNIQUEMENT quand le créateur choisit "utiliser le contenu du modèle" en
|
||||||
|
appliquant un thème (voir `routes/document/document_theme_apply.py` et
|
||||||
|
`document_engine/themes/`), jamais appelée sans confirmation explicite
|
||||||
|
côté client (action destructive, irréversible côté serveur). `seed_pages`
|
||||||
|
est une liste de pages, chaque page un dict
|
||||||
|
`{"vertical_align": "top"|"center"|"bottom", "blocks": [...]}`
|
||||||
|
(`vertical_align` optionnel, retombe sur `"top"`) ; chaque bloc de
|
||||||
|
`blocks` est `{"kind", "attributes", "children"}` (`children` optionnel,
|
||||||
|
uniquement pour un bloc `kind="row"` — un seul niveau de profondeur,
|
||||||
|
comme le moteur de rangées lui-même). Les attributs fournis sont
|
||||||
|
fusionnés sur `element_default_attributes(kind)`, jamais un remplacement
|
||||||
|
brut. Les nouvelles pages sont créées AVANT que les anciennes soient
|
||||||
|
supprimées (jamais l'inverse) : passer par zéro page, même brièvement,
|
||||||
|
déclenche le filet de sécurité de `ensure_document_pages_schema` (un
|
||||||
|
support a toujours au moins une page), qui recréerait une "Page 1" vide
|
||||||
|
parasite.
|
||||||
|
- **Retour** : aucun.
|
||||||
|
- **Exceptions** : aucune levée explicitement.
|
||||||
|
|
||||||
|
## `delete_document_page(slug: str, page_id: int) -> None`
|
||||||
|
Supprime une page ET ses éléments (`DELETE FROM _document_elements
|
||||||
|
WHERE page_id = ?` explicite — la contrainte `FOREIGN KEY ... ON DELETE
|
||||||
|
CASCADE` n'existe que pour les supports créés après l'ajout des pages,
|
||||||
|
voir `db/supports/ensure_document_pages_schema.py` pour les anciens).
|
||||||
|
Ne refuse JAMAIS de supprimer la dernière page restante — un support à
|
||||||
|
0 page est un état valide (voir plus haut).
|
||||||
|
- **Retour** : aucun.
|
||||||
|
- **Exceptions** : aucune.
|
||||||
|
|
||||||
|
## `delete_all_document_pages(slug: str) -> None`
|
||||||
|
Supprime TOUTES les pages du support d'un coup, et tous leurs éléments
|
||||||
|
de contenu avec elles (retour utilisateur : "une option dans page pour
|
||||||
|
supprimer toute les page d'un coup") — action destructive et
|
||||||
|
irréversible côté serveur, jamais appelée sans confirmation explicite
|
||||||
|
côté client (voir `static/document/js/document-editor.js`,
|
||||||
|
`forgeDocDeleteAllPages`). Le support se retrouve à 0 page, exactement
|
||||||
|
comme un support neuf.
|
||||||
|
- **Retour** : aucun.
|
||||||
|
- **Exceptions** : aucune.
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from ..elements.add_document_element import add_document_element
|
||||||
|
from ..elements.update_document_element_attributes import update_document_element_attributes
|
||||||
|
from ..labels.element_kind_labels import element_default_attributes
|
||||||
|
from .add_document_page import add_document_page
|
||||||
|
from .delete_document_page import delete_document_page
|
||||||
|
from .list_document_pages import list_document_pages
|
||||||
|
from .set_document_page_vertical_align import set_document_page_vertical_align
|
||||||
|
|
||||||
|
|
||||||
|
def replace_document_content(slug: str, seed_pages: list[dict[str, Any]]) -> None:
|
||||||
|
"""Remplace TOUT le contenu du support par `seed_pages` — utilisé
|
||||||
|
UNIQUEMENT quand le créateur choisit explicitement "utiliser le
|
||||||
|
contenu du modèle" en appliquant un thème (voir routes/document/
|
||||||
|
document_theme_apply.py, jamais appelée sans confirmation explicite
|
||||||
|
côté client : action destructive, irréversible côté serveur).
|
||||||
|
|
||||||
|
`seed_pages` est une liste de pages, chaque page un dict
|
||||||
|
`{"vertical_align": "top"|"center"|"bottom", "blocks": [...]}`
|
||||||
|
(`vertical_align` optionnel, retombe sur "top" — voir
|
||||||
|
set_document_page_vertical_align.VERTICAL_ALIGNS) ; chaque bloc de
|
||||||
|
`blocks` est `{"kind": str, "attributes": dict, "children": [...]}`
|
||||||
|
(`children` optionnel, uniquement pour un bloc `kind="row"` — chaque
|
||||||
|
enfant a la même forme `{"kind", "attributes"}`, sans petit-enfant :
|
||||||
|
le moteur de rangées ne descend jamais à plus d'un niveau, voir
|
||||||
|
document_engine/rendering/render_document_element.py::_render_row).
|
||||||
|
Les attributs fournis sont FUSIONNÉS sur
|
||||||
|
element_default_attributes(kind) (jamais un remplacement brut) pour
|
||||||
|
rester valides même si `seed_pages` n'en précise qu'une partie."""
|
||||||
|
# Les nouvelles pages sont créées AVANT de supprimer les anciennes
|
||||||
|
# (jamais l'inverse) : ça évite que le support affiche un état "0 page"
|
||||||
|
# transitoire pendant le remplacement (même si 0 page est désormais un
|
||||||
|
# état par ailleurs valide, voir list_document_pages.py — ce n'est
|
||||||
|
# qu'une question d'ordre d'écriture ici, plus un filet de sécurité).
|
||||||
|
old_page_ids = [page["id"] for page in list_document_pages(slug)]
|
||||||
|
for seed_page in seed_pages:
|
||||||
|
page_id = add_document_page(slug)
|
||||||
|
vertical_align = seed_page.get("vertical_align")
|
||||||
|
if vertical_align:
|
||||||
|
set_document_page_vertical_align(slug, page_id, vertical_align)
|
||||||
|
for block in seed_page.get("blocks", []):
|
||||||
|
_add_seed_block(slug, page_id, block, parent_id=None)
|
||||||
|
for old_page_id in old_page_ids:
|
||||||
|
delete_document_page(slug, old_page_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _add_seed_block(slug: str, page_id: int, block: dict[str, Any], parent_id: int | None) -> None:
|
||||||
|
kind = block["kind"]
|
||||||
|
element_id = add_document_element(slug, kind, page_id=page_id, parent_id=parent_id)
|
||||||
|
attributes = {**element_default_attributes(kind), **block.get("attributes", {})}
|
||||||
|
update_document_element_attributes(slug, element_id, attributes)
|
||||||
|
for child in block.get("children", []):
|
||||||
|
_add_seed_block(slug, page_id, child, parent_id=element_id)
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
from db.supports import connect_support, ensure_document_pages_schema
|
||||||
|
|
||||||
|
VERTICAL_ALIGNS = ("top", "center", "bottom")
|
||||||
|
|
||||||
|
|
||||||
|
def set_document_page_vertical_align(slug: str, page_id: int, vertical_align: str) -> None:
|
||||||
|
"""Règle l'alignement vertical du CONTENU d'une page (`justify-content`
|
||||||
|
de `.docPageContent`, voir static/document/document-editor.css) —
|
||||||
|
réglable depuis le panneau Propriétés quand l'onglet "Pages" est actif
|
||||||
|
(retour utilisateur du 24/09/2026), jamais un attribut par élément (ça
|
||||||
|
concerne la page entière, pas un bloc de contenu particulier). Une
|
||||||
|
valeur hors de `VERTICAL_ALIGNS` retombe silencieusement sur "top"
|
||||||
|
(état par défaut) plutôt que de lever — même philosophie défensive que
|
||||||
|
`update_document_page` pour un titre vide."""
|
||||||
|
ensure_document_pages_schema(slug)
|
||||||
|
clean_align = vertical_align if vertical_align in VERTICAL_ALIGNS else "top"
|
||||||
|
conn = connect_support(slug)
|
||||||
|
conn.execute("UPDATE _document_pages SET vertical_align = ? WHERE id = ?", (clean_align, page_id))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
from db.supports import connect_support, ensure_document_pages_schema
|
||||||
|
|
||||||
|
|
||||||
|
def update_document_page(slug: str, page_id: int, title: str) -> None:
|
||||||
|
"""Renomme une page — un titre vide (une fois `.strip()`-é) est
|
||||||
|
silencieusement ignoré (la page garde son titre précédent) plutôt que
|
||||||
|
de la faire disparaître de la bande d'onglets, même philosophie que
|
||||||
|
les sanitize_X_config de document_engine/labels/ : jamais un état
|
||||||
|
structurel invalide."""
|
||||||
|
ensure_document_pages_schema(slug)
|
||||||
|
clean_title = title.strip()
|
||||||
|
if not clean_title:
|
||||||
|
return
|
||||||
|
conn = connect_support(slug)
|
||||||
|
conn.execute("UPDATE _document_pages SET title = ? WHERE id = ?", (clean_title, page_id))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
"""Attributs de mise en forme de "boîte" PARTAGÉS par plusieurs kinds de
|
||||||
|
contenu (padding/margin/background_color/border_radius/border/align_self)
|
||||||
|
— un seul et même jeu d'attributs et une seule fonction de rendu pour ne
|
||||||
|
jamais dupliquer cette logique entre `_render_text`/`_render_image`/
|
||||||
|
`_render_button`/etc. (voir retour utilisateur du 26/09/2026 : audit
|
||||||
|
complet des réglages manquants, à ajouter élément par élément en
|
||||||
|
réutilisant CE module à chaque fois plutôt que de le réécrire)."""
|
||||||
|
|
||||||
|
import html as html_lib
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
BORDER_SIDES = ("top", "right", "bottom", "left")
|
||||||
|
|
||||||
|
_DEFAULT_BORDER_SIDE = {"style": "none", "width": "1px", "color": "var(--doc-border)"}
|
||||||
|
|
||||||
|
|
||||||
|
def default_border() -> dict[str, dict[str, str]]:
|
||||||
|
"""Nouveau dict à chaque appel (jamais un littéral partagé/muté par
|
||||||
|
référence entre plusieurs éléments, même précaution que
|
||||||
|
DEFAULT_QUIZ_CONFIG côté labels)."""
|
||||||
|
return {side: dict(_DEFAULT_BORDER_SIDE) for side in BORDER_SIDES}
|
||||||
|
|
||||||
|
|
||||||
|
BOX_DEFAULTS = {
|
||||||
|
"padding": "",
|
||||||
|
"margin": "",
|
||||||
|
"background_color": "",
|
||||||
|
"border_radius": "",
|
||||||
|
"align_self": "stretch",
|
||||||
|
"width": "",
|
||||||
|
"max_width": "",
|
||||||
|
"height": "",
|
||||||
|
"min_height": "",
|
||||||
|
"max_height": "",
|
||||||
|
"min_width": "",
|
||||||
|
"box_shadow": "",
|
||||||
|
"opacity": "",
|
||||||
|
"content_align": "top",
|
||||||
|
}
|
||||||
|
|
||||||
|
_CONTENT_ALIGN_TO_JUSTIFY_CONTENT = {"center": "center", "bottom": "flex-end"}
|
||||||
|
|
||||||
|
|
||||||
|
# (clé d'attribut, propriété CSS) — chaque paire suit exactement le même
|
||||||
|
# patron (lire/nettoyer/ajouter si non vide) ; une simple table de
|
||||||
|
# correspondance ici évite un enchaînement de blocs `if` identiques
|
||||||
|
# (complexité cognitive réduite, voir _render_simple_properties).
|
||||||
|
_SIMPLE_PROPERTIES = (
|
||||||
|
("padding", "padding"),
|
||||||
|
("margin", "margin"),
|
||||||
|
("background_color", "background-color"),
|
||||||
|
("border_radius", "border-radius"),
|
||||||
|
("width", "width"),
|
||||||
|
("max_width", "max-width"),
|
||||||
|
("height", "height"),
|
||||||
|
("min_height", "min-height"),
|
||||||
|
("max_height", "max-height"),
|
||||||
|
("min_width", "min-width"),
|
||||||
|
("box_shadow", "box-shadow"),
|
||||||
|
("opacity", "opacity"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _render_simple_properties(a: dict[str, Any]) -> list[str]:
|
||||||
|
parts = []
|
||||||
|
for attr_key, css_prop in _SIMPLE_PROPERTIES:
|
||||||
|
value = str(a.get(attr_key, "")).strip()
|
||||||
|
if value:
|
||||||
|
parts.append(f"{css_prop}:{html_lib.escape(value)};")
|
||||||
|
return parts
|
||||||
|
|
||||||
|
|
||||||
|
def _render_border(a: dict[str, Any]) -> list[str]:
|
||||||
|
"""Un côté à `style="none"` (ou absent) ne produit aucune déclaration
|
||||||
|
pour ce côté, jamais un `border-top:none` explicite."""
|
||||||
|
parts = []
|
||||||
|
border = a.get("border") or {}
|
||||||
|
for side in BORDER_SIDES:
|
||||||
|
side_border = border.get(side) or {}
|
||||||
|
style = str(side_border.get("style", "none"))
|
||||||
|
if style and style != "none":
|
||||||
|
width = html_lib.escape(str(side_border.get("width", "1px")))
|
||||||
|
color = html_lib.escape(str(side_border.get("color", "var(--doc-border)")))
|
||||||
|
parts.append(f"border-{side}:{width} {html_lib.escape(style)} {color};")
|
||||||
|
return parts
|
||||||
|
|
||||||
|
|
||||||
|
def render_box_style(a: dict[str, Any]) -> str:
|
||||||
|
"""Construit les déclarations CSS inline communes à plusieurs kinds à
|
||||||
|
partir des attributs listés dans `_SIMPLE_PROPERTIES` + `border`/
|
||||||
|
`align_self` de `a` — chaîne vide pour tout attribut absent ou à sa
|
||||||
|
valeur par défaut (aucun style ajouté, comportement historique
|
||||||
|
inchangé). `border` est un dict à 4 clés (`BORDER_SIDES`), chacune
|
||||||
|
`{"style", "width", "color"}`.
|
||||||
|
- **Retour** : les déclarations CSS (`"propriete:valeur; ..."`),
|
||||||
|
jamais vide ni `None`.
|
||||||
|
- **Exceptions** : aucune."""
|
||||||
|
parts = _render_simple_properties(a) + _render_border(a)
|
||||||
|
|
||||||
|
# align-self ne fait quoi que ce soit d'utile QUE si l'élément a par
|
||||||
|
# ailleurs une taille bornée (max_width/width) — voir la note dans
|
||||||
|
# element_kind_labels.md — mais reste toujours sûr à poser seul
|
||||||
|
# ("stretch" est déjà le comportement par défaut d'un enfant flex en
|
||||||
|
# colonne, donc jamais ajouté explicitement pour ne rien changer).
|
||||||
|
align_self = str(a.get("align_self", "stretch"))
|
||||||
|
if align_self and align_self != "stretch":
|
||||||
|
parts.append(f"align-self:{html_lib.escape(align_self)};")
|
||||||
|
|
||||||
|
return " ".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def render_content_align(a: dict[str, Any]) -> str:
|
||||||
|
"""Alignement vertical du CONTENU à l'intérieur de son propre bloc —
|
||||||
|
utile UNIQUEMENT une fois qu'une hauteur fixe/minimale dépasse la
|
||||||
|
hauteur naturelle du contenu (retour utilisateur du 26/09/2026 : "je
|
||||||
|
peux augmenter la hauteur d'un conteneur mais pas l'alignement
|
||||||
|
vertical à l'intérieur"). Jamais fusionné dans `render_box_style` :
|
||||||
|
contrairement à `align_self` (position du BLOC dans SON parent, la
|
||||||
|
même logique convient à tout consommateur), l'alignement du CONTENU
|
||||||
|
dépend de l'axe interne du conteneur — correct en `justify-content`
|
||||||
|
pour un conteneur en colonne (texte, liste), mais un bouton
|
||||||
|
(rangée : icône + texte) gère déjà cet axe autrement (`align-items`,
|
||||||
|
voir static/document/document-editor.css, .docButton) : chaque
|
||||||
|
renderer qui veut ce comportement l'appelle donc explicitement lui-
|
||||||
|
même (voir _render_text/_render_list/_render_image), jamais
|
||||||
|
automatiquement pour tous les kinds.
|
||||||
|
- **Retour** : `""` si `content_align` est absent ou `"top"` (défaut,
|
||||||
|
comportement historique inchangé), sinon la déclaration
|
||||||
|
`justify-content:...;`.
|
||||||
|
- **Exceptions** : aucune."""
|
||||||
|
content_align = str(a.get("content_align", "top"))
|
||||||
|
justify_content = _CONTENT_ALIGN_TO_JUSTIFY_CONTENT.get(content_align)
|
||||||
|
return f"justify-content:{justify_content};" if justify_content else ""
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
import html as html_lib
|
import html as html_lib
|
||||||
import json
|
import json
|
||||||
|
import random
|
||||||
|
import urllib.parse
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
_SHAPE_TAGS = {"rectangle": "rect", "cercle": "circle", "trait": "line"}
|
from .box_style import render_box_style, render_content_align
|
||||||
|
from .sanitize_svg_markup import sanitize_svg_markup
|
||||||
|
|
||||||
|
|
||||||
def render_document(elements: list[dict[str, Any]]) -> str:
|
def render_document(elements: list[dict[str, Any]]) -> str:
|
||||||
@@ -38,40 +41,6 @@ def _render_row(el: dict[str, Any], children_by_parent: dict[int | None, list[di
|
|||||||
return f'<div class="docRow" data-element-id="{el["id"]}" data-kind="row" style="{style}">{inner}</div>'
|
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 = {
|
_STYLE_PRESETS = {
|
||||||
"titre1": ("clamp(1.6rem,4vw,2rem)", 800, 1.15),
|
"titre1": ("clamp(1.6rem,4vw,2rem)", 800, 1.15),
|
||||||
"titre2": ("1.3rem", 800, 1.25),
|
"titre2": ("1.3rem", 800, 1.25),
|
||||||
@@ -84,29 +53,337 @@ def _render_text(el: dict[str, Any], _children_by_parent: dict[int | None, list[
|
|||||||
a = el["attributes"]
|
a = el["attributes"]
|
||||||
content = html_lib.escape(str(a.get("content", "")))
|
content = html_lib.escape(str(a.get("content", "")))
|
||||||
style_name = a.get("style", "paragraphe")
|
style_name = a.get("style", "paragraphe")
|
||||||
font_size, base_weight, line_height = _STYLE_PRESETS.get(style_name, _STYLE_PRESETS["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
|
weight = 800 if a.get("bold") else base_weight
|
||||||
font_style = "italic" if a.get("italic") else "normal"
|
font_style = "italic" if a.get("italic") else "normal"
|
||||||
text_decoration = "underline" if a.get("underline") else "none"
|
|
||||||
|
# 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")))
|
align = html_lib.escape(str(a.get("align", "left")))
|
||||||
color = html_lib.escape(str(a.get("color", "var(--forge-text)")))
|
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 = (
|
style = (
|
||||||
f"font-size:{font_size}; font-weight:{weight}; line-height:{line_height}; "
|
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};"
|
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}"
|
||||||
|
content_align = render_content_align(a)
|
||||||
|
if content_align:
|
||||||
|
style += f" {content_align}"
|
||||||
|
|
||||||
return f'<div class="docText" data-element-id="{el["id"]}" data-kind="{el["kind"]}" style="{style}">{content}</div>'
|
return f'<div class="docText" data-element-id="{el["id"]}" data-kind="{el["kind"]}" style="{style}">{content}</div>'
|
||||||
|
|
||||||
|
|
||||||
|
_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:
|
def _render_image(el: dict[str, Any], _children_by_parent: dict[int | None, list[dict[str, Any]]]) -> str:
|
||||||
a = el["attributes"]
|
a = el["attributes"]
|
||||||
src = html_lib.escape(str(a.get("src", "")))
|
click_behavior = str(a.get("click_behavior", ""))
|
||||||
alt = html_lib.escape(str(a.get("alt", "")))
|
link_url = str(a.get("link_url", "")).strip()
|
||||||
if not src:
|
caption = str(a.get("caption", "")).strip()
|
||||||
return (
|
# render_box_style (padding/margin/fond/bordure/largeur/position du
|
||||||
f'<div class="docImage docImagePlaceholder" data-element-id="{el["id"]}" data-kind="image">'
|
# bloc, dont align-self) doit se poser sur l'élément RÉELLEMENT
|
||||||
f"Image — aucun fichier choisi</div>"
|
# top-niveau — celui qui est l'enfant direct du flex-column de la
|
||||||
|
# page (voir .docPageContent, static/document/document-editor.css) —
|
||||||
|
# jamais sur l'<img>/<div> 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'<div class="docImage" data-element-id="{el["id"]}" data-kind="image"{media_style_attr}>{sanitized}</div>'
|
||||||
)
|
)
|
||||||
return f'<img class="docImage" data-element-id="{el["id"]}" data-kind="image" src="{src}" alt="{alt}">'
|
else:
|
||||||
|
src = html_lib.escape(str(a.get("src", "")))
|
||||||
|
alt = html_lib.escape(str(a.get("alt", "")))
|
||||||
|
if not src:
|
||||||
|
media = (
|
||||||
|
f'<div class="docImage docImagePlaceholder" data-element-id="{el["id"]}" '
|
||||||
|
f'data-kind="image"{media_style_attr}>Image — aucun fichier choisi</div>'
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
media = (
|
||||||
|
f'<img class="docImage" data-element-id="{el["id"]}" data-kind="image" '
|
||||||
|
f'src="{src}" alt="{alt}"{media_style_attr}{loading_attr}>'
|
||||||
|
)
|
||||||
|
|
||||||
|
# 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'<a class="docImageLink" href="{href}" target="_blank" '
|
||||||
|
f'rel="noopener noreferrer"{wrapper_style_attr}>{media}</a>'
|
||||||
|
)
|
||||||
|
elif click_behavior == "lightbox":
|
||||||
|
wrapper_style_attr = f' style="{box_style}"' if (box_style and not caption) else ""
|
||||||
|
media = f'<div class="docImageLightboxTrigger"{wrapper_style_attr}>{media}</div>'
|
||||||
|
|
||||||
|
if caption:
|
||||||
|
# render_content_align (retour utilisateur du 26/09/2026 :
|
||||||
|
# "je peux augmenter la hauteur d'un conteneur mais pas
|
||||||
|
# l'alignement vertical à l'intérieur") n'a de sens ici QUE pour
|
||||||
|
# la figure (conteneur flex-colonne à plusieurs enfants réels —
|
||||||
|
# image + légende) : jamais sur l'<img> seul ni sur les
|
||||||
|
# enveloppes lien/plein écran, qui ne sont pas des conteneurs
|
||||||
|
# flex-colonne à plusieurs enfants.
|
||||||
|
figure_style = " ".join(p for p in (box_style, render_content_align(a)) if p)
|
||||||
|
figure_style_attr = f' style="{figure_style}"' if figure_style else ""
|
||||||
|
media = (
|
||||||
|
f'<figure class="docImageFigure"{figure_style_attr}>{media}'
|
||||||
|
f'<figcaption class="docImageCaption">{html_lib.escape(caption)}</figcaption></figure>'
|
||||||
|
)
|
||||||
|
|
||||||
|
return media
|
||||||
|
|
||||||
|
|
||||||
|
_LIST_STYLE_TYPES = {
|
||||||
|
"liste_puces": ("disc", "circle", "square", "none"),
|
||||||
|
"liste_numerotee": (
|
||||||
|
"decimal",
|
||||||
|
"decimal-leading-zero",
|
||||||
|
"lower-roman",
|
||||||
|
"upper-roman",
|
||||||
|
"lower-alpha",
|
||||||
|
"upper-alpha",
|
||||||
|
"none",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _list_text_style(a: dict[str, Any]) -> list[str]:
|
||||||
|
"""Typographie de la liste ENTIÈRE (jamais par élément individuel,
|
||||||
|
voir element_kind_labels.py — portée actée avec l'utilisateur)."""
|
||||||
|
parts = []
|
||||||
|
if a.get("bold"):
|
||||||
|
parts.append("font-weight:700;")
|
||||||
|
if a.get("italic"):
|
||||||
|
parts.append("font-style:italic;")
|
||||||
|
if a.get("underline"):
|
||||||
|
parts.append("text-decoration:underline;")
|
||||||
|
font_family = str(a.get("font_family", "")).strip()
|
||||||
|
if font_family:
|
||||||
|
parts.append(f"font-family:{html_lib.escape(font_family)};")
|
||||||
|
font_size = str(a.get("font_size", "")).strip()
|
||||||
|
if font_size:
|
||||||
|
parts.append(f"font-size:{html_lib.escape(font_size)};")
|
||||||
|
line_height = str(a.get("line_height", "")).strip()
|
||||||
|
if line_height:
|
||||||
|
parts.append(f"line-height:{html_lib.escape(line_height)};")
|
||||||
|
text_color = str(a.get("text_color", "")).strip()
|
||||||
|
if text_color:
|
||||||
|
parts.append(f"color:{html_lib.escape(text_color)};")
|
||||||
|
return parts
|
||||||
|
|
||||||
|
|
||||||
|
def _list_marker_style(a: dict[str, Any], kind: str) -> list[str]:
|
||||||
|
"""Puces/numéros — `marker_color`/`marker_size` passent par des
|
||||||
|
PROPRIÉTÉS PERSONNALISÉES CSS (héritées jusqu'au pseudo-élément
|
||||||
|
`::marker` de chaque <li>, voir .docList li::marker dans
|
||||||
|
document-editor.css) : un style inline posé sur le <ul>/<ol> ne peut
|
||||||
|
pas cibler directement le `::marker` de ses enfants autrement."""
|
||||||
|
parts = []
|
||||||
|
list_style_type = str(a.get("list_style_type", ""))
|
||||||
|
if list_style_type in _LIST_STYLE_TYPES.get(kind, ()):
|
||||||
|
parts.append(f"list-style-type:{list_style_type};")
|
||||||
|
position_inside = str(a.get("list_style_position", "outside")) == "inside"
|
||||||
|
if position_inside:
|
||||||
|
parts.append("list-style-position:inside;")
|
||||||
|
# Retour utilisateur du 26/09/2026 : "si j'enlève les puces ou que les
|
||||||
|
# puces se mettent à l'intérieur, il reste un espace devant la liste,
|
||||||
|
# cet espace doit être supprimé" — le padding-left:1.4em par défaut
|
||||||
|
# (document-editor.css, .docList) réserve la place d'une puce
|
||||||
|
# EXTÉRIEURE ; il n'a plus lieu d'être dès que la puce n'est plus là
|
||||||
|
# ("none") ou qu'elle rejoint le flux du texte ("inside").
|
||||||
|
if list_style_type == "none" or position_inside:
|
||||||
|
parts.append("padding-left:0;")
|
||||||
|
marker_color = str(a.get("marker_color", "")).strip()
|
||||||
|
if marker_color:
|
||||||
|
parts.append(f"--doc-marker-color:{html_lib.escape(marker_color)};")
|
||||||
|
marker_size = str(a.get("marker_size", "")).strip()
|
||||||
|
if marker_size:
|
||||||
|
parts.append(f"--doc-marker-size:{html_lib.escape(marker_size)};")
|
||||||
|
# Puce personnalisée (image SVG) : liste à puces UNIQUEMENT, une
|
||||||
|
# puce imagée n'a pas de sens sur une liste numérotée. list-style-image
|
||||||
|
# prime visuellement sur list-style-type dès qu'il est posé (aucun
|
||||||
|
# conflit à gérer entre les deux).
|
||||||
|
svg_markup = str(a.get("svg_markup", "")).strip() if kind == "liste_puces" else ""
|
||||||
|
if svg_markup:
|
||||||
|
sanitized = sanitize_svg_markup(svg_markup)
|
||||||
|
encoded = urllib.parse.quote(sanitized)
|
||||||
|
parts.append(f'list-style-image:url("data:image/svg+xml,{encoded}");')
|
||||||
|
return parts
|
||||||
|
|
||||||
|
|
||||||
|
def _list_item_style(a: dict[str, Any]) -> list[str]:
|
||||||
|
"""`item_padding`/`item_spacing` s'appliquent à CHAQUE <li>, jamais au
|
||||||
|
conteneur <ul>/<ol> lui-même — même mécanisme de propriété
|
||||||
|
personnalisée CSS héritée que `_list_marker_style` ci-dessus (voir
|
||||||
|
.docList li dans document-editor.css). Une valeur UNIFORME partagée
|
||||||
|
par tous les éléments (retour utilisateur du 26/09/2026), jamais
|
||||||
|
réglable par élément individuel."""
|
||||||
|
parts = []
|
||||||
|
item_padding = str(a.get("item_padding", "")).strip()
|
||||||
|
if item_padding:
|
||||||
|
parts.append(f"--doc-item-padding:{html_lib.escape(item_padding)};")
|
||||||
|
item_spacing = str(a.get("item_spacing", "")).strip()
|
||||||
|
if item_spacing:
|
||||||
|
parts.append(f"--doc-item-spacing:{html_lib.escape(item_spacing)};")
|
||||||
|
return parts
|
||||||
|
|
||||||
|
|
||||||
|
def _render_list(el: dict[str, Any], _children_by_parent: dict[int | None, list[dict[str, Any]]]) -> str:
|
||||||
|
"""Liste à puces (<ul>) ou numérotée (<ol>) — le kind lui-même décide
|
||||||
|
la balise, pas un attribut "ordered" séparé (voir
|
||||||
|
element_kind_labels.element_default_attributes). Une "items" vide
|
||||||
|
rend une liste vide plutôt qu'un placeholder : contrairement à une
|
||||||
|
image sans fichier, une liste sans élément n'a rien d'anormal à
|
||||||
|
afficher (le créateur vient peut-être de tout supprimer avant d'en
|
||||||
|
retaper un)."""
|
||||||
|
a = el["attributes"]
|
||||||
|
kind = el["kind"]
|
||||||
|
items = a.get("items", [])
|
||||||
|
tag = "ol" if kind == "liste_numerotee" else "ul"
|
||||||
|
items_html = "".join(f"<li>{html_lib.escape(str(item))}</li>" for item in items)
|
||||||
|
|
||||||
|
style = " ".join(
|
||||||
|
_list_text_style(a)
|
||||||
|
+ _list_marker_style(a, kind)
|
||||||
|
+ _list_item_style(a)
|
||||||
|
+ [render_box_style(a), render_content_align(a)]
|
||||||
|
)
|
||||||
|
style = style.strip()
|
||||||
|
style_attr = f' style="{style}"' if style else ""
|
||||||
|
|
||||||
|
return f'<{tag} class="docList" data-element-id="{el["id"]}" data-kind="{kind}"{style_attr}>{items_html}</{tag}>'
|
||||||
|
|
||||||
|
|
||||||
|
_BUTTON_TEXT_TRANSFORMS = ("uppercase", "lowercase", "capitalize")
|
||||||
|
|
||||||
|
|
||||||
|
def _button_text_style(a: dict[str, Any]) -> list[str]:
|
||||||
|
"""Déclarations de typographie propres au bouton (jamais dans
|
||||||
|
box_style.py, partagé avec d'autres kinds qui n'ont pas tous une
|
||||||
|
notion de texte)."""
|
||||||
|
parts = []
|
||||||
|
if a.get("bold"):
|
||||||
|
# 800 (jamais 700, déjà le poids par défaut du CSS de base) :
|
||||||
|
# "gras" ne fait que RENFORCER le poids existant, jamais
|
||||||
|
# l'affaiblir — aucun bouton déjà créé ne change d'apparence tant
|
||||||
|
# que cette case n'est pas cochée explicitement.
|
||||||
|
parts.append("font-weight:800;")
|
||||||
|
if a.get("italic"):
|
||||||
|
parts.append("font-style:italic;")
|
||||||
|
text_transform = str(a.get("text_transform", "none"))
|
||||||
|
if text_transform in _BUTTON_TEXT_TRANSFORMS:
|
||||||
|
parts.append(f"text-transform:{text_transform};")
|
||||||
|
font_family = str(a.get("font_family", "")).strip()
|
||||||
|
if font_family:
|
||||||
|
parts.append(f"font-family:{html_lib.escape(font_family)};")
|
||||||
|
font_size = str(a.get("font_size", "")).strip()
|
||||||
|
if font_size:
|
||||||
|
parts.append(f"font-size:{html_lib.escape(font_size)};")
|
||||||
|
letter_spacing = str(a.get("letter_spacing", "")).strip()
|
||||||
|
if letter_spacing:
|
||||||
|
parts.append(f"letter-spacing:{html_lib.escape(letter_spacing)};")
|
||||||
|
text_color = str(a.get("text_color", "")).strip()
|
||||||
|
if text_color:
|
||||||
|
parts.append(f"color:{html_lib.escape(text_color)};")
|
||||||
|
return parts
|
||||||
|
|
||||||
|
|
||||||
|
def _button_icon_html(a: dict[str, Any]) -> str:
|
||||||
|
svg_markup = str(a.get("svg_markup", "")).strip()
|
||||||
|
if not svg_markup:
|
||||||
|
return ""
|
||||||
|
icon_size = str(a.get("icon_size", "")).strip()
|
||||||
|
size_style = (
|
||||||
|
f' style="width:{html_lib.escape(icon_size)}; height:{html_lib.escape(icon_size)};"' if icon_size else ""
|
||||||
|
)
|
||||||
|
return f'<span class="docButtonIcon"{size_style}>{sanitize_svg_markup(svg_markup)}</span>'
|
||||||
|
|
||||||
|
|
||||||
def _render_button(el: dict[str, Any], _children_by_parent: dict[int | None, list[dict[str, Any]]]) -> str:
|
def _render_button(el: dict[str, Any], _children_by_parent: dict[int | None, list[dict[str, Any]]]) -> str:
|
||||||
@@ -114,9 +391,70 @@ def _render_button(el: dict[str, Any], _children_by_parent: dict[int | None, lis
|
|||||||
label = html_lib.escape(str(a.get("label", "Bouton")))
|
label = html_lib.escape(str(a.get("label", "Bouton")))
|
||||||
target = html_lib.escape(str(a.get("target", "")))
|
target = html_lib.escape(str(a.get("target", "")))
|
||||||
target_attr = f' data-target="{target}"' if target else ""
|
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 ""
|
||||||
|
|
||||||
|
style = " ".join(_button_text_style(a) + [render_box_style(a)]).strip()
|
||||||
|
style_attr = f' style="{style}"' if style else ""
|
||||||
|
|
||||||
|
icon_html = _button_icon_html(a)
|
||||||
|
label_span = f'<span class="docButtonLabel">{label}</span>'
|
||||||
|
inner = (
|
||||||
|
f"{label_span}{icon_html}" if str(a.get("icon_position", "before")) == "after" else f"{icon_html}{label_span}"
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
f'<button type="button" class="docButton" data-element-id="{el["id"]}" data-kind="bouton"{target_attr}>'
|
f'<button type="button" class="docButton" data-element-id="{el["id"]}" '
|
||||||
f"{label}</button>"
|
f'data-kind="bouton"{target_attr}{attachment_attr}{style_attr}>{inner}</button>'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _render_badge(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", "")))
|
||||||
|
svg_markup = str(a.get("svg_markup", "")).strip()
|
||||||
|
icon_html = f'<span class="docBadgeIcon">{sanitize_svg_markup(svg_markup)}</span>' if svg_markup else ""
|
||||||
|
|
||||||
|
style_parts = []
|
||||||
|
width = str(a.get("width", "")).strip()
|
||||||
|
if width:
|
||||||
|
# Fixer une largeur implique de ne plus s'étirer sur toute la
|
||||||
|
# largeur de .docPageContent (comportement par défaut d'un enfant
|
||||||
|
# flex en colonne, voir static/document/document-editor.css) —
|
||||||
|
# les deux vont toujours ensemble, jamais l'un sans l'autre.
|
||||||
|
style_parts.append(f"align-self:flex-start; width:{html_lib.escape(width)};")
|
||||||
|
border_radius = str(a.get("border_radius", "")).strip()
|
||||||
|
if border_radius:
|
||||||
|
style_parts.append(f"border-radius:{html_lib.escape(border_radius)};")
|
||||||
|
if a.get("bold"):
|
||||||
|
style_parts.append("font-weight:800;")
|
||||||
|
if a.get("uppercase"):
|
||||||
|
style_parts.append("text-transform:uppercase;")
|
||||||
|
style_attr = f' style="{" ".join(style_parts)}"' if style_parts else ""
|
||||||
|
|
||||||
|
return (
|
||||||
|
f'<div class="docBadge" data-element-id="{el["id"]}" data-kind="badge"{style_attr}>{icon_html}{content}</div>'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _render_carte(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", "")))
|
||||||
|
title = html_lib.escape(str(a.get("title", "")))
|
||||||
|
description = html_lib.escape(str(a.get("description", "")))
|
||||||
|
return (
|
||||||
|
f'<div class="docCard" data-element-id="{el["id"]}" data-kind="carte">'
|
||||||
|
f'<div class="docCardLabel">{label}</div>'
|
||||||
|
f'<div class="docCardTitle">{title}</div>'
|
||||||
|
f'<div class="docCardDescription">{description}</div>'
|
||||||
|
f"</div>"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -193,24 +531,354 @@ def _render_quiz(el: dict[str, Any], _children_by_parent: dict[int | None, list[
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _render_association_player(config: dict[str, Any]) -> str:
|
||||||
|
"""Plateau de glisser-déposer RÉELLEMENT interactif — affiché
|
||||||
|
uniquement en Mode Aperçu, même principe que _render_quiz_player :
|
||||||
|
aucun aller-retour serveur, tout le déroulé (glisser une carte de
|
||||||
|
gauche sur son emplacement de droite, ou cliquer les deux) est géré
|
||||||
|
par static/document/js/document-editor.js à partir du JSON embarqué.
|
||||||
|
Les deux colonnes sont mélangées INDÉPENDAMMENT (sinon la position
|
||||||
|
suffirait à deviner l'association, sans avoir à lire quoi que ce
|
||||||
|
soit) — random.shuffle : mélange d'affichage pour un mini-jeu,
|
||||||
|
jamais un usage cryptographique (voir CODE_QUALITY.md)."""
|
||||||
|
pairs = config["pairs"]
|
||||||
|
left_items = [{"pair_index": i, "text": p["left"]} for i, p in enumerate(pairs)]
|
||||||
|
right_items = [{"pair_index": i, "text": p["right"]} for i, p in enumerate(pairs)]
|
||||||
|
random.shuffle(left_items) # NOSONAR python:S2245 - melange d'affichage, pas un usage cryptographique
|
||||||
|
random.shuffle(right_items) # NOSONAR python:S2245 - idem
|
||||||
|
config_json = html_lib.escape(json.dumps({"left": left_items, "right": right_items}), quote=True)
|
||||||
|
return (
|
||||||
|
f'<div class="docAssocPlayer" data-assoc-config="{config_json}">'
|
||||||
|
f'<div class="docAssocCard">'
|
||||||
|
f'<div class="docQuizKicker">Association</div>'
|
||||||
|
f'<div class="docAssocTitle">Associez chaque élément à sa correspondance</div>'
|
||||||
|
f'<div class="docAssocMeta"><span class="docAssocProg"></span></div>'
|
||||||
|
f'<div class="docAssocBoard">'
|
||||||
|
f'<div class="docAssocColumn docAssocColumnLeft"></div>'
|
||||||
|
f'<div class="docAssocColumn docAssocColumnRight"></div>'
|
||||||
|
f"</div>"
|
||||||
|
f'<div class="docAssocFeedback"></div>'
|
||||||
|
# Le plateau reste affiché une fois toutes les paires trouvées
|
||||||
|
# (voir docs/plan/PLAN.md, retour utilisateur du 20/09/2026) —
|
||||||
|
# seul ce bouton apparaît (.is-visible posé par
|
||||||
|
# forgeDocAssociationMatchResult), jamais un écran de résultat
|
||||||
|
# séparé qui remplacerait le plateau (ça reste le comportement du
|
||||||
|
# Quiz, seul mini-jeu concerné par un écran de fin distinct).
|
||||||
|
f'<div class="docMinigameRestartBar">'
|
||||||
|
f'<button type="button" class="docAssocRestartBtn">Recommencer</button>'
|
||||||
|
f"</div>"
|
||||||
|
f"</div></div>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _render_association(el: dict[str, Any], _children_by_parent: dict[int | None, list[dict[str, Any]]]) -> str:
|
||||||
|
from ..labels.association_config import sanitize_association_config
|
||||||
|
|
||||||
|
config = sanitize_association_config(el["attributes"])
|
||||||
|
theme_color = html_lib.escape(str(config["theme_color"]))
|
||||||
|
pair_count = len(config["pairs"])
|
||||||
|
pair_label = "paire" if pair_count <= 1 else "paires"
|
||||||
|
player_html = _render_association_player(config) if config["pairs"] else ""
|
||||||
|
return (
|
||||||
|
f'<div class="docMinigame" data-element-id="{el["id"]}" data-kind="association" '
|
||||||
|
f'style="border-color:{theme_color};">'
|
||||||
|
f'<div class="docMinigameBadge">'
|
||||||
|
f'<span class="docMinigameLabel">Association</span>'
|
||||||
|
f'<span class="docMinigamePlaceholder">{pair_count} {pair_label}</span>'
|
||||||
|
f"</div>"
|
||||||
|
f"{player_html}"
|
||||||
|
f"</div>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _render_memory_player(config: dict[str, Any]) -> str:
|
||||||
|
"""Plateau de Memory RÉELLEMENT interactif — affiché uniquement en
|
||||||
|
Mode Aperçu, même principe que _render_quiz_player/
|
||||||
|
_render_association_player. En mode "paire", chaque carte définie par
|
||||||
|
le créateur est dupliquée en deux instances partageant le même
|
||||||
|
card_index (l'appariement se fait dessus) ; en mode "single", une
|
||||||
|
seule instance par carte (simple retournement, sans appariement).
|
||||||
|
Les instances sont mélangées une seule fois ici (jamais recalculées
|
||||||
|
à chaque rendu répété d'un même Aperçu, voir la remarque dans
|
||||||
|
static/document/js/document-editor.js sur la ré-init au
|
||||||
|
rafraîchissement du canevas) puis embarquées en JSON."""
|
||||||
|
cards = config["cards"]
|
||||||
|
mode = config["mode"]
|
||||||
|
instances = []
|
||||||
|
for i, card in enumerate(cards):
|
||||||
|
instances.append({"card_index": i, "recto": card["recto"], "verso": card["verso"]})
|
||||||
|
if mode == "paire":
|
||||||
|
instances.append({"card_index": i, "recto": card["recto"], "verso": card["verso"]})
|
||||||
|
random.shuffle(instances) # NOSONAR python:S2245 - melange d'affichage, pas un usage cryptographique
|
||||||
|
config_json = html_lib.escape(json.dumps({"mode": mode, "cards": instances}), quote=True)
|
||||||
|
return (
|
||||||
|
f'<div class="docMemoryPlayer" data-memory-config="{config_json}">'
|
||||||
|
f'<div class="docMemoryCardWrap">'
|
||||||
|
f'<div class="docQuizKicker">Memory</div>'
|
||||||
|
f'<div class="docAssocTitle docMemoryTitle"></div>'
|
||||||
|
f'<div class="docAssocMeta"><span class="docMemoryProg"></span></div>'
|
||||||
|
f'<div class="docMemoryGrid"></div>'
|
||||||
|
# Même choix que l'Association ci-dessus : le plateau reste
|
||||||
|
# affiché une fois le jeu terminé, seul ce bouton apparaît.
|
||||||
|
f'<div class="docMinigameRestartBar">'
|
||||||
|
f'<button type="button" class="docMemoryRestartBtn">Recommencer</button>'
|
||||||
|
f"</div>"
|
||||||
|
f"</div></div>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _render_memory(el: dict[str, Any], _children_by_parent: dict[int | None, list[dict[str, Any]]]) -> str:
|
||||||
|
from ..labels.memory_config import sanitize_memory_config
|
||||||
|
|
||||||
|
config = sanitize_memory_config(el["attributes"])
|
||||||
|
theme_color = html_lib.escape(str(config["theme_color"]))
|
||||||
|
card_count = len(config["cards"])
|
||||||
|
card_label = "carte" if card_count <= 1 else "cartes"
|
||||||
|
mode_label = "mode paire" if config["mode"] == "paire" else "mode simple"
|
||||||
|
subtitle = f"{card_count} {card_label} · {mode_label}"
|
||||||
|
player_html = _render_memory_player(config) if config["cards"] else ""
|
||||||
|
return (
|
||||||
|
f'<div class="docMinigame" data-element-id="{el["id"]}" data-kind="memory" '
|
||||||
|
f'style="border-color:{theme_color};">'
|
||||||
|
f'<div class="docMinigameBadge">'
|
||||||
|
f'<span class="docMinigameLabel">Memory</span>'
|
||||||
|
f'<span class="docMinigamePlaceholder">{html_lib.escape(subtitle)}</span>'
|
||||||
|
f"</div>"
|
||||||
|
f"{player_html}"
|
||||||
|
f"</div>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_MOTS_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||||
|
_MOTS_DIRECTIONS = (
|
||||||
|
(0, 1), # horizontale, gauche -> droite
|
||||||
|
(1, 0), # verticale, haut -> bas
|
||||||
|
(1, 1), # diagonale droite, haut-gauche -> bas-droite
|
||||||
|
(1, -1), # diagonale gauche, haut-droite -> bas-gauche
|
||||||
|
)
|
||||||
|
_MOTS_MAX_PLACEMENT_ATTEMPTS = 200
|
||||||
|
_MOTS_MAX_GRID_GROWTH_ATTEMPTS = 20
|
||||||
|
|
||||||
|
|
||||||
|
def _mots_grid_size_for_words(words: list[str]) -> int:
|
||||||
|
"""Taille de départ de la grille (carrée) — assez grande pour loger le
|
||||||
|
plus long mot ET laisser assez de cases libres pour le remplissage
|
||||||
|
aléatoire, sans grille disproportionnée pour une courte liste de mots.
|
||||||
|
_build_mots_grid grandit cette taille si le placement échoue malgré
|
||||||
|
tout (mots qui se contraignent mutuellement), donc une estimation
|
||||||
|
approximative suffit ici."""
|
||||||
|
from ..labels.mots_config import MIN_WORD_LENGTH
|
||||||
|
|
||||||
|
longest = max((len(w) for w in words), default=MIN_WORD_LENGTH)
|
||||||
|
total_letters = sum(len(w) for w in words)
|
||||||
|
return max(longest, 8, int(total_letters**0.5) + 2)
|
||||||
|
|
||||||
|
|
||||||
|
def _mots_can_place_word(
|
||||||
|
grid: list[list[str]], word: str, row: int, col: int, delta_row: int, delta_col: int, size: int
|
||||||
|
) -> bool:
|
||||||
|
for i, letter in enumerate(word):
|
||||||
|
r, c = row + i * delta_row, col + i * delta_col
|
||||||
|
if not (0 <= r < size and 0 <= c < size):
|
||||||
|
return False
|
||||||
|
if grid[r][c] not in ("", letter):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _mots_place_word(grid: list[list[str]], word: str, size: int) -> list[list[int]] | None:
|
||||||
|
"""None si aucun emplacement libre n'a été trouvé après le nombre
|
||||||
|
d'essais autorisé — laisse l'appelant décider (agrandir la grille et
|
||||||
|
tout retenter, voir _build_mots_grid) plutôt que de placer le mot de
|
||||||
|
force en écrasant des lettres déjà posées."""
|
||||||
|
for _ in range(_MOTS_MAX_PLACEMENT_ATTEMPTS):
|
||||||
|
delta_row, delta_col = random.choice(_MOTS_DIRECTIONS) # nosec B311 # noqa: S311 - placement de mot, jamais crypto # NOSONAR python:S2245
|
||||||
|
row = random.randint(0, size - 1) # nosec B311 # noqa: S311 - idem # NOSONAR python:S2245
|
||||||
|
col = random.randint(0, size - 1) # nosec B311 # noqa: S311 - idem # NOSONAR python:S2245
|
||||||
|
if not _mots_can_place_word(grid, word, row, col, delta_row, delta_col, size):
|
||||||
|
continue
|
||||||
|
cells = []
|
||||||
|
for i, letter in enumerate(word):
|
||||||
|
r, c = row + i * delta_row, col + i * delta_col
|
||||||
|
grid[r][c] = letter
|
||||||
|
cells.append([r, c])
|
||||||
|
return cells
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _mots_attempt_placement(words: list[str], size: int) -> dict[str, Any] | None:
|
||||||
|
grid: list[list[str]] = [["" for _ in range(size)] for _ in range(size)]
|
||||||
|
placed_words = []
|
||||||
|
# Les mots les plus longs sont placés en premier : ce sont les plus
|
||||||
|
# difficiles à caser, autant le faire tant que la grille est encore
|
||||||
|
# majoritairement libre.
|
||||||
|
for word in sorted(words, key=len, reverse=True):
|
||||||
|
cells = _mots_place_word(grid, word, size)
|
||||||
|
if cells is None:
|
||||||
|
return None
|
||||||
|
placed_words.append({"text": word, "cells": cells})
|
||||||
|
for r in range(size):
|
||||||
|
for c in range(size):
|
||||||
|
if not grid[r][c]:
|
||||||
|
grid[r][c] = random.choice(_MOTS_ALPHABET) # nosec B311 # noqa: S311 - lettre de remplissage, jamais crypto # NOSONAR python:S2245
|
||||||
|
return {"size": size, "grid": grid, "words": placed_words}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_mots_grid(words: list[str]) -> dict[str, Any]:
|
||||||
|
"""Construit la grille ET la position exacte de chaque mot (jamais
|
||||||
|
recalculée côté client, voir _render_mots_player) : une grille carrée,
|
||||||
|
chaque mot placé horizontalement/verticalement/en diagonale (deux sens
|
||||||
|
de diagonale seulement, jamais à l'envers — voir _MOTS_DIRECTIONS),
|
||||||
|
les cases restantes remplies de lettres aléatoires. Si un mot ne
|
||||||
|
trouve pas sa place (mots qui se contraignent mutuellement), la
|
||||||
|
grille entière est agrandie et le placement retenté depuis zéro,
|
||||||
|
plutôt que d'abandonner silencieusement ce mot."""
|
||||||
|
if not words:
|
||||||
|
return {"size": 0, "grid": [], "words": []}
|
||||||
|
size = _mots_grid_size_for_words(words)
|
||||||
|
for _ in range(_MOTS_MAX_GRID_GROWTH_ATTEMPTS):
|
||||||
|
result = _mots_attempt_placement(words, size)
|
||||||
|
if result is not None:
|
||||||
|
return result
|
||||||
|
size += 2
|
||||||
|
# Filet de sécurité théorique : avec MAX_WORDS=10 mots de
|
||||||
|
# MAX_WORD_LENGTH=20 lettres au plus, la grille finit toujours par
|
||||||
|
# être assez grande pour tous les loger bien avant cette limite.
|
||||||
|
return _mots_attempt_placement(words, size) or {"size": size, "grid": [], "words": []}
|
||||||
|
|
||||||
|
|
||||||
|
def _render_mots_player(config: dict[str, Any]) -> str:
|
||||||
|
"""Grille de mots mêlés RÉELLEMENT interactive — affichée uniquement
|
||||||
|
en Mode Aperçu, même principe que les autres mini-jeux : la grille ET
|
||||||
|
la position exacte de chaque mot sont calculées ICI côté serveur
|
||||||
|
(_build_mots_grid, jamais recalculées côté client) puis embarquées en
|
||||||
|
JSON ; static/document/js/document-editor.js compare les coordonnées
|
||||||
|
de la sélection de l'apprenant aux coordonnées exactes de chaque mot
|
||||||
|
— jamais une simple comparaison de texte, qui se tromperait sur des
|
||||||
|
lettres partagées entre deux mots qui se croisent."""
|
||||||
|
built = _build_mots_grid(config["words"])
|
||||||
|
config_json = html_lib.escape(json.dumps(built), quote=True)
|
||||||
|
return (
|
||||||
|
f'<div class="docMotsPlayer" data-mots-config="{config_json}">'
|
||||||
|
f'<div class="docAssocCard">'
|
||||||
|
f'<div class="docQuizKicker">Mots mêlés</div>'
|
||||||
|
f'<div class="docAssocTitle">Retrouvez chaque mot caché dans la grille</div>'
|
||||||
|
f'<div class="docAssocMeta"><span class="docMotsProg"></span></div>'
|
||||||
|
f'<div class="docMotsBoard">'
|
||||||
|
f'<div class="docMotsGrid"></div>'
|
||||||
|
f'<div class="docMotsWordList"></div>'
|
||||||
|
f"</div>"
|
||||||
|
f'<div class="docMinigameRestartBar">'
|
||||||
|
f'<button type="button" class="docMotsRestartBtn">Recommencer</button>'
|
||||||
|
f"</div>"
|
||||||
|
f"</div></div>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _render_mots(el: dict[str, Any], _children_by_parent: dict[int | None, list[dict[str, Any]]]) -> str:
|
||||||
|
from ..labels.mots_config import sanitize_mots_config
|
||||||
|
|
||||||
|
config = sanitize_mots_config(el["attributes"])
|
||||||
|
theme_color = html_lib.escape(str(config["theme_color"]))
|
||||||
|
word_count = len(config["words"])
|
||||||
|
word_label = "mot" if word_count <= 1 else "mots"
|
||||||
|
player_html = _render_mots_player(config) if config["words"] else ""
|
||||||
|
return (
|
||||||
|
f'<div class="docMinigame" data-element-id="{el["id"]}" data-kind="mots" '
|
||||||
|
f'style="border-color:{theme_color};">'
|
||||||
|
f'<div class="docMinigameBadge">'
|
||||||
|
f'<span class="docMinigameLabel">Mots mêlés</span>'
|
||||||
|
f'<span class="docMinigamePlaceholder">{word_count} {word_label}</span>'
|
||||||
|
f"</div>"
|
||||||
|
f"{player_html}"
|
||||||
|
f"</div>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _render_scenario_player(config: dict[str, Any]) -> str:
|
||||||
|
"""Mise en situation RÉELLEMENT interactive — affichée uniquement en
|
||||||
|
Mode Aperçu, même principe que les autres mini-jeux : aucun
|
||||||
|
aller-retour serveur, tout le déroulé (navigation dans l'arbre de
|
||||||
|
décision, scénario suivant) est géré par static/document/js/
|
||||||
|
document-editor.js à partir du JSON embarqué (voir scenario_config.py
|
||||||
|
pour la forme exacte d'un scénario : {"title", "nodes"}, nodes[0]
|
||||||
|
étant la situation initiale). Les scénarios (plusieurs arbres
|
||||||
|
indépendants) gardent l'ORDRE d'écriture du créateur (contrairement à
|
||||||
|
l'Association/Memory/Mots mêlés, jamais mélangés) : ce sont des mises
|
||||||
|
en situation séquentielles, pas des éléments à faire correspondre ou
|
||||||
|
retrouver — un mélange n'aurait ici aucun sens pédagogique. Un seul
|
||||||
|
bloc de texte (.docScenarioSituation) sert successivement à afficher
|
||||||
|
le texte de chaque nœud visité : une fois un choix fait, le texte du
|
||||||
|
nœud suivant REMPLACE le précédent (les boutons de choix disparaissent
|
||||||
|
avec lui) — volontairement PAS le comportement du Quiz, où la
|
||||||
|
question resterait affichée à côté d'un encart de feedback séparé.
|
||||||
|
Aucune notion de bonne/mauvaise réponse ici (retour utilisateur du
|
||||||
|
20/09/2026 : "il n'y a pas de notion vrai/faux, l'utilisateur observe
|
||||||
|
les conséquences") — un nœud sans choix est simplement une fin de
|
||||||
|
branche. Réutilise les classes visuelles du Quiz
|
||||||
|
(.docQuizOptions/.docQuizNextBar/.docQuizQuestionText) plutôt que de
|
||||||
|
dupliquer ces règles, même esprit que .docAssocCard/
|
||||||
|
.docMinigameRestartBar."""
|
||||||
|
config_json = html_lib.escape(json.dumps({"scenarios": config["scenarios"]}), quote=True)
|
||||||
|
return (
|
||||||
|
f'<div class="docScenarioPlayer" data-scenario-config="{config_json}">'
|
||||||
|
f'<div class="docAssocCard">'
|
||||||
|
f'<div class="docQuizKicker">Scénario</div>'
|
||||||
|
f'<div class="docAssocMeta"><span class="docScenarioProg"></span></div>'
|
||||||
|
f'<div class="docScenarioSituation docQuizQuestionText"></div>'
|
||||||
|
f'<div class="docScenarioChoices docQuizOptions"></div>'
|
||||||
|
f'<div class="docQuizNextBar">'
|
||||||
|
f'<button type="button" class="docQuizNextBtn docScenarioNextBtn">Scénario suivant</button>'
|
||||||
|
f"</div>"
|
||||||
|
# Même choix que l'Association/Memory/Mots mêlés : le dernier
|
||||||
|
# scénario reste affiché une fois répondu, seul ce bouton
|
||||||
|
# apparaît (jamais un écran de résultat séparé — ça reste le
|
||||||
|
# comportement du Quiz).
|
||||||
|
f'<div class="docMinigameRestartBar">'
|
||||||
|
f'<button type="button" class="docScenarioRestartBtn">Recommencer</button>'
|
||||||
|
f"</div>"
|
||||||
|
f"</div></div>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _render_scenario(el: dict[str, Any], _children_by_parent: dict[int | None, list[dict[str, Any]]]) -> str:
|
||||||
|
from ..labels.scenario_config import sanitize_scenario_config
|
||||||
|
|
||||||
|
config = sanitize_scenario_config(el["attributes"])
|
||||||
|
theme_color = html_lib.escape(str(config["theme_color"]))
|
||||||
|
scenario_count = len(config["scenarios"])
|
||||||
|
scenario_label = "scénario" if scenario_count <= 1 else "scénarios"
|
||||||
|
player_html = _render_scenario_player(config) if config["scenarios"] else ""
|
||||||
|
return (
|
||||||
|
f'<div class="docMinigame" data-element-id="{el["id"]}" data-kind="scenario" '
|
||||||
|
f'style="border-color:{theme_color};">'
|
||||||
|
f'<div class="docMinigameBadge">'
|
||||||
|
f'<span class="docMinigameLabel">Scénario</span>'
|
||||||
|
f'<span class="docMinigamePlaceholder">{scenario_count} {scenario_label}</span>'
|
||||||
|
f"</div>"
|
||||||
|
f"{player_html}"
|
||||||
|
f"</div>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _render_unknown(el: dict[str, Any], _children_by_parent: dict[int | None, list[dict[str, Any]]]) -> str:
|
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>'
|
return f'<div class="docUnknown" data-element-id="{el["id"]}">Type inconnu : {html_lib.escape(el["kind"])}</div>'
|
||||||
|
|
||||||
|
|
||||||
_RENDERERS = {
|
_RENDERERS = {
|
||||||
"row": _render_row,
|
"row": _render_row,
|
||||||
"rectangle": _render_shape,
|
|
||||||
"cercle": _render_shape,
|
|
||||||
"triangle": _render_shape,
|
|
||||||
"trait": _render_shape,
|
|
||||||
"titre": _render_text,
|
"titre": _render_text,
|
||||||
"paragraphe": _render_text,
|
"paragraphe": _render_text,
|
||||||
"image": _render_image,
|
"image": _render_image,
|
||||||
"bouton": _render_button,
|
"bouton": _render_button,
|
||||||
|
"liste_puces": _render_list,
|
||||||
|
"liste_numerotee": _render_list,
|
||||||
|
"badge": _render_badge,
|
||||||
|
"carte": _render_carte,
|
||||||
"quiz": _render_quiz,
|
"quiz": _render_quiz,
|
||||||
"association": _render_minigame_placeholder,
|
"association": _render_association,
|
||||||
"memory": _render_minigame_placeholder,
|
"memory": _render_memory,
|
||||||
"mots": _render_minigame_placeholder,
|
"mots": _render_mots,
|
||||||
"scenario": _render_minigame_placeholder,
|
"scenario": _render_scenario,
|
||||||
"zones": _render_minigame_placeholder,
|
"zones": _render_minigame_placeholder,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,13 +28,126 @@ regroupement à chaque appel.
|
|||||||
- **Rangée** (`row`) : conteneur flex (`gap`/`align-items`/
|
- **Rangée** (`row`) : conteneur flex (`gap`/`align-items`/
|
||||||
`justify-content` réels depuis `attributes`), enfants rendus
|
`justify-content` réels depuis `attributes`), enfants rendus
|
||||||
récursivement.
|
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
|
- **Texte** (`titre`/`paragraphe`) : `<div>` stylé selon `style` (préréglage
|
||||||
taille/graisse/interligne) et `bold`/`italic`/`underline`/`align`/`color`.
|
taille/graisse/interligne) et `bold`/`italic`/`underline`/`strikethrough`/
|
||||||
- **Image** : `<img>`, ou un bloc placeholder si `src` est vide.
|
`align`/`color`. `underline`/`strikethrough` se combinent dans un seul
|
||||||
- **Bouton** : `<button>` avec son `label` et un `data-target` optionnel.
|
`text-decoration` (`"underline line-through"` si les deux sont actifs).
|
||||||
|
`font_size`/`line_height` (vides par défaut) remplacent les valeurs du
|
||||||
|
préréglage `style` SANS toucher `font-weight` (toujours piloté par le
|
||||||
|
préréglage + `bold`). `text_transform` (`"none"` par défaut) ajoute
|
||||||
|
`text-transform` quand différent de `"none"`. `font_family`/
|
||||||
|
`letter_spacing`/`text_shadow` (vides par défaut) ajoutent leur
|
||||||
|
déclaration CSS respective quand non vides. `max_width` (optionnel, ex.
|
||||||
|
`"60ch"`, `"480px"`) ajoute `max-width` au style inline quand non vide —
|
||||||
|
pleine largeur de `.docPageContent` par défaut, 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).
|
||||||
|
Termine par `render_box_style(a)` (voir `box_style.py` ci-dessous) pour
|
||||||
|
`padding`/`margin`/`background_color`/`border_radius`/`border`/
|
||||||
|
`align_self` — attributs PARTAGÉS avec d'autres kinds, jamais dupliqués
|
||||||
|
ici (audit du 26/09/2026, réglages manquants à couvrir élément par
|
||||||
|
élément en réutilisant ce module).
|
||||||
|
- **Image** : `<img>`, ou un bloc placeholder si `src` est vide — OU, si
|
||||||
|
`attributes["svg_markup"]` est non vide (prioritaire sur `src`), un
|
||||||
|
`<div>` portant directement ce fragment SVG nettoyé par
|
||||||
|
`sanitize_svg_markup` (voir `sanitize_svg_markup.py` ci-dessous) : un
|
||||||
|
contenu vectoriel dessiné/collé par le créateur plutôt qu'un fichier
|
||||||
|
hébergé. Style inline : `object_fit` (`"cover"`/`"contain"`/`"fill"`,
|
||||||
|
toute autre valeur ignorée), `aspect_ratio` (valeur CSS libre, ex.
|
||||||
|
`"16 / 9"`), `filter_preset` (`"grayscale"`/`"sepia"`/`"blur"`, mappé
|
||||||
|
vers une vraie valeur `filter` CSS fixe — jamais une valeur de filtre
|
||||||
|
libre) + les attributs de boîte partagés (`render_box_style`, voir
|
||||||
|
`box_style.py`). `lazy_load` (`True`) ajoute `loading="lazy"` sur
|
||||||
|
l'`<img>` uniquement (comportement, pas du style). `click_behavior`
|
||||||
|
(`""`/`"link"`/`"lightbox"`) enveloppe le tout dans un `<a target="_blank"
|
||||||
|
rel="noopener noreferrer">` (si `link_url` est aussi renseigné) ou un
|
||||||
|
`<div class="docImageLightboxTrigger">` — les deux ne deviennent
|
||||||
|
réellement cliquables qu'en Mode Aperçu (voir static/document/js/
|
||||||
|
document-editor.js::forgeDocBindCanvasInteractions/
|
||||||
|
forgeDocOpenImageLightbox), même principe que les mini-jeux et la
|
||||||
|
pièce jointe d'un bouton. `caption` (non vide) enveloppe le tout dans
|
||||||
|
un `<figure><figcaption>` échappée.
|
||||||
|
- **Bouton** : `<button>` avec son `label` (enveloppé dans
|
||||||
|
`<span class="docButtonLabel">`), un `data-target` optionnel
|
||||||
|
(navigation) et un `data-attachment-filename` optionnel — marqueur
|
||||||
|
mécanique posé quand un fichier a été joint (voir
|
||||||
|
`routes/document/document_element_upload_attachment.py`), jamais
|
||||||
|
l'URL de téléchargement elle-même (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). Style inline (audit du
|
||||||
|
26/09/2026, réglages manquants — colonne "Bouton") : `bold` pousse
|
||||||
|
`font-weight` à `800` (jamais en dessous du `700` déjà posé par le CSS
|
||||||
|
de base — "gras" ne fait que renforcer, jamais affaiblir, aucune
|
||||||
|
régression visuelle sur les boutons déjà créés), `italic`/
|
||||||
|
`text_transform` (`"uppercase"`/`"lowercase"`/`"capitalize"`, jamais
|
||||||
|
`"none"`)/`font_family`/`font_size`/`letter_spacing`/`text_color`
|
||||||
|
(vides par défaut) + les attributs de boîte partagés
|
||||||
|
(`render_box_style`, voir `box_style.py`). `svg_markup` (optionnel,
|
||||||
|
nettoyé par `sanitize_svg_markup`) ajoute un `<span
|
||||||
|
class="docButtonIcon">` avant OU après `.docButtonLabel` selon
|
||||||
|
`icon_position` (`"before"` par défaut), dimensionné par `icon_size`
|
||||||
|
(vide = `1em`, suit la taille du texte). États interactifs
|
||||||
|
survol/actif : effet CSS universel (`filter`/`transform`, voir
|
||||||
|
`static/document/document-editor.css`), jamais configurable par
|
||||||
|
attribut — pas de notion d'état "désactivé" pour un bouton de contenu
|
||||||
|
(ce n'est pas un vrai contrôle de formulaire).
|
||||||
|
- **Étiquette** (`badge`) : `<div>` portant `attributes["content"]`
|
||||||
|
échappé, précédé d'un `<span class="docBadgeIcon">` optionnel si
|
||||||
|
`svg_markup` est non vide (nettoyé par `sanitize_svg_markup`, même
|
||||||
|
mécanisme que le mode SVG de `"image"`). `width` (non vide) ajoute
|
||||||
|
`align-self:flex-start; width:{valeur};` en style inline — fixer une
|
||||||
|
largeur implique TOUJOURS de sortir de l'étirement pleine largeur par
|
||||||
|
défaut d'un enfant flex en colonne (voir static/document/
|
||||||
|
document-editor.css, `.docPageContent`), jamais l'un sans l'autre.
|
||||||
|
`border_radius` (non vide) ajoute `border-radius:{valeur};`. `bold`/
|
||||||
|
`uppercase` ajoutent respectivement `font-weight:800;`/
|
||||||
|
`text-transform:uppercase;` quand `True`. Tous ces attributs sont vides
|
||||||
|
ou `False` par défaut (comportement historique inchangé, aucun style
|
||||||
|
inline ajouté).
|
||||||
|
- **Carte** (`carte`) : `<div>` composé de trois blocs enfants
|
||||||
|
(`label`/`title`/`description`, tous échappés) — contenu pur, aucune
|
||||||
|
couleur/forme choisie ici (voir `element_kind_labels.md`).
|
||||||
|
- **Liste à puces/numérotée** (`liste_puces`/`liste_numerotee`) :
|
||||||
|
`<ul>` ou `<ol>` selon le `kind` (fonction privée `_render_list`,
|
||||||
|
partagée par les deux) — un `<li>` par entrée de `attributes["items"]`.
|
||||||
|
Une liste vide rend `<ul>`/`<ol>` sans enfant plutôt qu'un placeholder :
|
||||||
|
contrairement à une image sans fichier, ce n'est pas un état anormal.
|
||||||
|
Style inline (audit du 26/09/2026, portée actée avec l'utilisateur :
|
||||||
|
la LISTE ENTIÈRE uniquement, jamais par élément individuel ni de
|
||||||
|
sous-listes) : `bold`/`italic`/`underline`/`font_family`/`font_size`/
|
||||||
|
`line_height`/`text_color` (typo, `_list_text_style`) +
|
||||||
|
`list_style_type` (validé selon le `kind` — `disc`/`circle`/`square`/
|
||||||
|
`none` pour `liste_puces`, `decimal`/`decimal-leading-zero`/
|
||||||
|
`lower-roman`/`upper-roman`/`lower-alpha`/`upper-alpha`/`none` pour
|
||||||
|
`liste_numerotee`, toute autre valeur ignorée)/`list_style_position`
|
||||||
|
(`"outside"` par défaut) + les attributs de boîte partagés
|
||||||
|
(`render_box_style`, voir `box_style.py`). `marker_color`/
|
||||||
|
`marker_size`/`item_padding`/`item_spacing` (`_list_marker_style`/
|
||||||
|
`_list_item_style`) passent par des PROPRIÉTÉS PERSONNALISÉES CSS
|
||||||
|
(`--doc-marker-color`/`--doc-marker-size`/`--doc-item-padding`/
|
||||||
|
`--doc-item-spacing`) : un style inline sur le `<ul>`/`<ol>` ne peut
|
||||||
|
pas cibler directement le `::marker` ou le padding de ses `<li>`
|
||||||
|
enfants autrement — ces propriétés sont posées sur le conteneur et
|
||||||
|
consommées par `static/document/document-editor.css`
|
||||||
|
(`.docList li`/`.docList li::marker`), qui hérite jusque-là.
|
||||||
|
`svg_markup` (liste à puces UNIQUEMENT, ignoré pour `liste_numerotee`)
|
||||||
|
: puce personnalisée — nettoyé par `sanitize_svg_markup` puis encodé
|
||||||
|
en URI de données (`urllib.parse.quote`) pour `list-style-image`, qui
|
||||||
|
prime visuellement sur `list_style_type` dès qu'il est posé.
|
||||||
|
`render_content_align(a)` (voir `box_style.py`) également ajouté :
|
||||||
|
`.docList` est `display:flex; flex-direction:column;` (un `<li>` garde
|
||||||
|
son `display:list-item` propre — puce/numéro visibles — même une fois
|
||||||
|
flex-item, ce sont deux notions indépendantes en CSS).
|
||||||
|
**Bug réel corrigé** (retour utilisateur du 26/09/2026 : "si j'enlève
|
||||||
|
les puces ou que les puces se mettent à l'intérieur, il reste un
|
||||||
|
espace devant la liste, cet espace doit être supprimé") :
|
||||||
|
`padding-left:0;` est ajouté automatiquement dès que
|
||||||
|
`list_style_type="none"` ou `list_style_position="inside"` — le
|
||||||
|
`padding-left:1.4em` par défaut (`.docList`, réservé pour une puce
|
||||||
|
EXTÉRIEURE) n'a alors plus lieu d'être ; un `padding` uniforme réglé
|
||||||
|
explicitement par ailleurs (attributs de boîte partagés) reste
|
||||||
|
prioritaire (déclaré après, dans le même style inline).
|
||||||
- **Quiz** : toujours une carte résumant la config réelle (nombre de
|
- **Quiz** : toujours une carte résumant la config réelle (nombre de
|
||||||
questions, total des points via `quiz_total_points`, minuteur si
|
questions, total des points via `quiz_total_points`, minuteur si
|
||||||
activé) — sanitizée (`sanitize_quiz_config`) avant lecture, jamais un
|
activé) — sanitizée (`sanitize_quiz_config`) avant lecture, jamais un
|
||||||
@@ -48,7 +161,184 @@ regroupement à chaque appel.
|
|||||||
(`html.escape(..., quote=True)`) — static/document/js/document-editor.js
|
(`html.escape(..., quote=True)`) — static/document/js/document-editor.js
|
||||||
lit cet attribut et gère tout le déroulé (réponse/score/question
|
lit cet attribut et gère tout le déroulé (réponse/score/question
|
||||||
suivante/résultat) côté client, sans aucun aller-retour serveur.
|
suivante/résultat) côté client, sans aucun aller-retour serveur.
|
||||||
- **Autres mini-jeux** (`association`/`memory`/`mots`/`scenario`/`zones`) :
|
- **Association** : toujours une carte résumant la config réelle (nombre
|
||||||
carte placeholder portant le libellé du type (voir
|
de paires) — sanitizée (`sanitize_association_config`) avant lecture.
|
||||||
`document_engine/labels/element_kind_labels.py`) — emplacement réservé,
|
Si au moins une paire existe, s'y ajoute (fonction privée
|
||||||
formulaire de contenu dédié hors périmètre de cette passe.
|
`_render_association_player`) le plateau de glisser-déposer RÉEL et
|
||||||
|
interactif affiché en Mode Aperçu, masqué en édition par CSS
|
||||||
|
(`.docAssocPlayer`) : les deux colonnes (termes/correspondances) sont
|
||||||
|
mélangées INDÉPENDAMMENT (`random.shuffle`, mélange d'affichage — voir
|
||||||
|
`CODE_QUALITY.md`) puis embarquées en JSON dans un attribut
|
||||||
|
`data-assoc-config`, échappé pour l'HTML — même principe que le Quiz,
|
||||||
|
aucun aller-retour serveur pendant qu'on joue. Contrairement au Quiz,
|
||||||
|
le plateau reste affiché en permanence une fois la partie terminée :
|
||||||
|
seul le bouton "Recommencer" (`.docMinigameRestartBar`, partagé avec
|
||||||
|
Memory) apparaît, jamais d'écran de résultat séparé qui le
|
||||||
|
remplacerait.
|
||||||
|
- **Memory** : toujours une carte résumant la config réelle (nombre de
|
||||||
|
cartes définies, mode paire/simple) — sanitizée
|
||||||
|
(`sanitize_memory_config`) avant lecture. Si au moins une carte existe,
|
||||||
|
s'y ajoute (fonction privée `_render_memory_player`) le plateau de
|
||||||
|
retournement RÉEL et interactif : en mode `"paire"`, chaque carte
|
||||||
|
définie est DUPLIQUÉE en deux instances partageant le même
|
||||||
|
`card_index` (l'appariement se fait dessus, classique Memory) ; en mode
|
||||||
|
`"single"`, une seule instance par carte (simple retournement, sans
|
||||||
|
appariement). Les instances sont mélangées (`random.shuffle`, mélange
|
||||||
|
d'affichage — voir `CODE_QUALITY.md`) puis embarquées en JSON dans un
|
||||||
|
attribut `data-memory-config`, échappé pour l'HTML — même principe que
|
||||||
|
le Quiz/l'Association, aucun aller-retour serveur pendant qu'on joue.
|
||||||
|
Comme l'Association, le plateau reste affiché une fois toutes les
|
||||||
|
paires trouvées (ou toutes les cartes révélées en mode `"single"`) :
|
||||||
|
seul le bouton "Recommencer" (`.docMinigameRestartBar`) apparaît.
|
||||||
|
- **Mots mêlés** : toujours une carte résumant la config réelle (nombre
|
||||||
|
de mots) — sanitizée (`sanitize_mots_config`) avant lecture. Si au
|
||||||
|
moins un mot existe, s'y ajoute (fonction privée `_render_mots_player`)
|
||||||
|
la grille RÉELLE et interactive. La grille ET la position exacte de
|
||||||
|
chaque mot sont calculées ICI côté serveur (`_build_mots_grid`, jamais
|
||||||
|
recalculées côté client) : chaque mot est placé horizontalement,
|
||||||
|
verticalement, ou en diagonale (haut-gauche→bas-droite ou
|
||||||
|
haut-droite→bas-gauche — jamais à l'envers), les lettres restantes
|
||||||
|
tirées au hasard (`random.choice`/`random.randint`, tirage de jeu —
|
||||||
|
voir `CODE_QUALITY.md`) ; si un mot ne trouve pas sa place, la grille
|
||||||
|
entière est agrandie et le placement retenté depuis zéro, plutôt que
|
||||||
|
d'abandonner silencieusement ce mot. Le tout (grille + coordonnées de
|
||||||
|
chaque mot) est embarqué en JSON dans un attribut `data-mots-config`,
|
||||||
|
échappé pour l'HTML — même principe que les autres mini-jeux, aucun
|
||||||
|
aller-retour serveur pendant qu'on joue : `static/document/js/
|
||||||
|
document-editor.js` compare les coordonnées EXACTES sélectionnées par
|
||||||
|
l'apprenant à celles de chaque mot (jamais une simple comparaison de
|
||||||
|
texte, qui se tromperait sur des lettres partagées entre deux mots qui
|
||||||
|
se croisent). Comme l'Association/Memory, la grille reste affichée une
|
||||||
|
fois tous les mots trouvés : seul le bouton "Recommencer"
|
||||||
|
(`.docMinigameRestartBar`) apparaît.
|
||||||
|
- **Scénario** : toujours une carte résumant la config réelle (nombre de
|
||||||
|
scénarios) — sanitizée (`sanitize_scenario_config`) avant lecture. Si
|
||||||
|
au moins un scénario existe, s'y ajoute (fonction privée
|
||||||
|
`_render_scenario_player`) la mise en situation RÉELLE et interactive :
|
||||||
|
un scénario est un ARBRE DE DÉCISION (voir `scenario_config.py` pour la
|
||||||
|
forme exacte — `nodes[0]` = situation initiale, chaque choix pointe
|
||||||
|
vers un autre nœud via `target_id`, un nœud sans choix est une fin de
|
||||||
|
branche), pas une simple question à une seule conséquence. Aucune
|
||||||
|
notion de bonne/mauvaise réponse (retour utilisateur du 20/09/2026 :
|
||||||
|
"il n'y a pas de notion vrai/faux, l'utilisateur observe les
|
||||||
|
conséquences") : l'apprenant choisit une option, le texte du nœud visé
|
||||||
|
REMPLACE l'affichage du nœud précédent (les boutons de choix
|
||||||
|
disparaissent avec lui), et ainsi de suite jusqu'à une fin de branche
|
||||||
|
— volontairement PAS le comportement du Quiz, où la question resterait
|
||||||
|
affichée à côté d'un encart de feedback séparé. Une fois une fin de
|
||||||
|
branche atteinte, passage au scénario (arbre) suivant. Les scénarios
|
||||||
|
gardent l'ORDRE d'écriture du créateur (jamais mélangés, contrairement
|
||||||
|
à Association/Memory/Mots mêlés — ce sont des mises en situation
|
||||||
|
séquentielles, pas des éléments à faire correspondre/retrouver).
|
||||||
|
Réutilise les classes visuelles du Quiz (`.docQuizOptions`/
|
||||||
|
`.docQuizNextBar`/`.docQuizQuestionText`) plutôt que de dupliquer ces
|
||||||
|
règles. L'arbre complet (tous les nœuds/choix de tous les scénarios)
|
||||||
|
est embarqué en JSON dans un attribut `data-scenario-config`, échappé
|
||||||
|
pour l'HTML — même principe que les autres mini-jeux, aucun
|
||||||
|
aller-retour serveur pendant qu'on joue : toute la navigation dans
|
||||||
|
l'arbre se fait côté client. Comme l'Association/Memory/Mots mêlés
|
||||||
|
(mais contrairement au Quiz), le dernier scénario reste affiché une
|
||||||
|
fois une fin de branche atteinte : seul le bouton "Recommencer"
|
||||||
|
(`.docMinigameRestartBar`) apparaît. L'arbre lui-même se construit
|
||||||
|
dans une MODALE dédiée depuis le panneau Propriétés (voir
|
||||||
|
`forgeDocOpenScenarioTreeModal`, static/document/js/document-editor.js)
|
||||||
|
— trop de structure (nœuds + choix + destinations) pour la colonne
|
||||||
|
étroite du panneau Propriétés, contrairement aux autres mini-jeux. La
|
||||||
|
modale est un VRAI graphe visuel (retour utilisateur du 21/09/2026) :
|
||||||
|
chaque nœud a une position `x`/`y` (voir `scenario_config.py`),
|
||||||
|
affiché comme une carte déplaçable à la souris sur un canevas ; chaque
|
||||||
|
choix relié à un `target_id` est dessiné comme une flèche SVG
|
||||||
|
étiquetée par son texte, jamais un simple menu déroulant. Éditer le
|
||||||
|
texte/les choix d'un nœud se fait dans l'inspecteur (panneau de
|
||||||
|
droite) du nœud sélectionné ; relier un choix se fait en cliquant
|
||||||
|
"Relier" puis le nœud cible sur le graphe (mode connexion, Échap
|
||||||
|
annule).
|
||||||
|
- **Autres mini-jeux** (`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.
|
||||||
|
|
||||||
|
## `box_style.py` — attributs de "boîte" partagés entre plusieurs kinds
|
||||||
|
|
||||||
|
Audit du 26/09/2026 (réglages manquants, à couvrir élément par élément) :
|
||||||
|
padding/margin/couleur de fond/arrondi/bordure par côté/position du bloc
|
||||||
|
sont des besoins IDENTIQUES pour la plupart des kinds de contenu — un
|
||||||
|
seul jeu d'attributs et une seule fonction de rendu ici, jamais réécrits
|
||||||
|
à chaque kind (voir `_render_text` pour le premier usage réel).
|
||||||
|
|
||||||
|
### `BORDER_SIDES: tuple[str, ...]`
|
||||||
|
`("top", "right", "bottom", "left")`.
|
||||||
|
|
||||||
|
### `default_border() -> dict[str, dict[str, str]]`
|
||||||
|
Un dict à 4 clés (`BORDER_SIDES`), chacune `{"style": "none", "width":
|
||||||
|
"1px", "color": "var(--doc-border)"}`.
|
||||||
|
- **Retour** : un NOUVEAU dict à chaque appel (jamais un littéral partagé
|
||||||
|
muté par référence entre deux éléments — même précaution que
|
||||||
|
`DEFAULT_QUIZ_CONFIG` côté `labels/`).
|
||||||
|
- **Exceptions** : aucune.
|
||||||
|
|
||||||
|
### `BOX_DEFAULTS: dict[str, Any]`
|
||||||
|
`{"padding": "", "margin": "", "background_color": "", "border_radius":
|
||||||
|
"", "align_self": "stretch", "width": "", "max_width": "", "height": "",
|
||||||
|
"min_height": "", "max_height": "", "min_width": "", "box_shadow": "",
|
||||||
|
"opacity": "", "content_align": "top"}` — `border` n'y figure PAS (voir
|
||||||
|
`default_border()`, à ajouter séparément par chaque appelant pour éviter
|
||||||
|
le partage par référence).
|
||||||
|
|
||||||
|
### `render_box_style(a: dict[str, Any]) -> str`
|
||||||
|
Construit les déclarations CSS inline pour chaque attribut listé dans
|
||||||
|
`BOX_DEFAULTS` (padding/margin/background_color/border_radius/width/
|
||||||
|
max_width/height/min_height/max_height/min_width/box_shadow/opacity) +
|
||||||
|
`border`/`align_self` de `a`, via une table de correspondance
|
||||||
|
(clé d'attribut, propriété CSS) plutôt qu'un bloc `if` par attribut
|
||||||
|
(complexité cognitive — voir `_render_simple_properties`/`_render_border`,
|
||||||
|
privées) — un attribut absent ou à sa valeur par défaut ne produit
|
||||||
|
AUCUNE déclaration (comportement historique inchangé). `border` est un
|
||||||
|
dict à 4 clés
|
||||||
|
(`BORDER_SIDES`), chacune `{"style", "width", "color"}` — un côté à
|
||||||
|
`style="none"` (ou absent) ne produit rien pour ce côté, jamais un
|
||||||
|
`border-top:none` explicite. `align-self` n'est ajouté que si différent
|
||||||
|
de `"stretch"` (déjà le comportement par défaut d'un enfant flex en
|
||||||
|
colonne).
|
||||||
|
- **Retour** : les déclarations CSS (`"propriete:valeur; ..."`), chaîne
|
||||||
|
vide si rien à ajouter.
|
||||||
|
- **Exceptions** : aucune.
|
||||||
|
|
||||||
|
### `render_content_align(a: dict[str, Any]) -> str`
|
||||||
|
Alignement vertical du CONTENU à l'intérieur de son propre bloc (retour
|
||||||
|
utilisateur du 26/09/2026 : "je peux augmenter la hauteur d'un
|
||||||
|
conteneur mais pas l'alignement vertical à l'intérieur") — `content_align`
|
||||||
|
(`"top"` par défaut, `"center"` ou `"bottom"`) mappé vers
|
||||||
|
`justify-content` (`""` pour `"top"`, comportement historique inchangé).
|
||||||
|
Jamais fusionné dans `render_box_style` : contrairement à `align_self`
|
||||||
|
(position du BLOC dans SON parent, valable pour tout consommateur),
|
||||||
|
l'alignement du CONTENU dépend de l'axe interne du conteneur —
|
||||||
|
`justify-content` convient à un conteneur en colonne (`_render_text`/
|
||||||
|
`_render_list`, dont les classes CSS `.docText`/`.docList` sont
|
||||||
|
`display:flex; flex-direction:column;`, et la figure d'une image
|
||||||
|
légendée, déjà flex-colonne), mais serait FAUX pour le Bouton (rangée
|
||||||
|
icône+texte : l'axe vertical y est déjà géré par `align-items`, voir
|
||||||
|
`static/document/document-editor.css`, `.docButton`) — chaque renderer
|
||||||
|
qui veut ce comportement l'appelle donc explicitement lui-même.
|
||||||
|
- **Retour** : `""` si absent/`"top"`/valeur inconnue, sinon
|
||||||
|
`"justify-content:...;"`.
|
||||||
|
- **Exceptions** : aucune.
|
||||||
|
|
||||||
|
## `sanitize_svg_markup.py` — nettoyage du contenu SVG inline d'une image
|
||||||
|
|
||||||
|
### `sanitize_svg_markup(markup: str) -> str`
|
||||||
|
Nettoie un fragment SVG selon une LISTE BLANCHE de balises/attributs
|
||||||
|
(`_ALLOWED_TAGS`/`_ALLOWED_ATTRS`, privées) — construit sur
|
||||||
|
`html.parser.HTMLParser` (tokenizer de balises pur, sans DTD ni
|
||||||
|
résolution d'entité externe) plutôt qu'un analyseur XML, qui resterait
|
||||||
|
exposé aux attaques classiques d'entité externe sur une entrée non
|
||||||
|
fiable. Toute balise absente de la liste blanche (`<script>`,
|
||||||
|
`<foreignObject>`, `<a>`, `<use>`, `<image>`...) disparaît AVEC son
|
||||||
|
contenu ; tout attribut absent (`on*`, `style`, `href`/`xlink:href`,
|
||||||
|
`class`...) disparaît seul, la balise porteuse étant conservée si elle
|
||||||
|
est autorisée. Appelé à CHAQUE rendu (`_render_image`), jamais
|
||||||
|
seulement à l'écriture — même défense en profondeur que
|
||||||
|
`html.escape` sur les autres kinds.
|
||||||
|
- **Retour** : le fragment SVG nettoyé, sûr à insérer tel quel dans le
|
||||||
|
HTML rendu.
|
||||||
|
- **Exceptions** : aucune.
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
"""Nettoyage d'un fragment SVG saisi/collé par le créateur comme contenu
|
||||||
|
d'une image (voir render_document_element._render_image) — jamais un
|
||||||
|
rendu direct de `attributes["svg_markup"]`, qui exposerait une injection
|
||||||
|
XSS triviale (`<script>`, `onload="..."`, `href="javascript:..."`).
|
||||||
|
|
||||||
|
Construit sur `html.parser.HTMLParser` (analyseur de balises pur, sans
|
||||||
|
DTD ni résolution d'entité externe) plutôt que sur un analyseur XML
|
||||||
|
(`xml.etree.ElementTree`), qui resterait exposé aux attaques classiques
|
||||||
|
d'entité externe/"milliard de rires" sur une entrée non fiable."""
|
||||||
|
|
||||||
|
from html.parser import HTMLParser
|
||||||
|
|
||||||
|
_ALLOWED_TAGS = {
|
||||||
|
"svg",
|
||||||
|
"path",
|
||||||
|
"circle",
|
||||||
|
"rect",
|
||||||
|
"line",
|
||||||
|
"polyline",
|
||||||
|
"polygon",
|
||||||
|
"ellipse",
|
||||||
|
"g",
|
||||||
|
"defs",
|
||||||
|
"lineargradient",
|
||||||
|
"radialgradient",
|
||||||
|
"stop",
|
||||||
|
"title",
|
||||||
|
"desc",
|
||||||
|
}
|
||||||
|
|
||||||
|
_ALLOWED_ATTRS = {
|
||||||
|
"viewbox",
|
||||||
|
"width",
|
||||||
|
"height",
|
||||||
|
"fill",
|
||||||
|
"stroke",
|
||||||
|
"stroke-width",
|
||||||
|
"stroke-linecap",
|
||||||
|
"stroke-linejoin",
|
||||||
|
"stroke-dasharray",
|
||||||
|
"opacity",
|
||||||
|
"fill-opacity",
|
||||||
|
"stroke-opacity",
|
||||||
|
"fill-rule",
|
||||||
|
"d",
|
||||||
|
"cx",
|
||||||
|
"cy",
|
||||||
|
"r",
|
||||||
|
"rx",
|
||||||
|
"ry",
|
||||||
|
"x",
|
||||||
|
"y",
|
||||||
|
"x1",
|
||||||
|
"y1",
|
||||||
|
"x2",
|
||||||
|
"y2",
|
||||||
|
"points",
|
||||||
|
"transform",
|
||||||
|
"offset",
|
||||||
|
"stop-color",
|
||||||
|
"stop-opacity",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class _SvgSanitizer(HTMLParser):
|
||||||
|
"""Reconstruit un fragment SVG balise par balise, en ne conservant que
|
||||||
|
les éléments/attributs de la liste blanche — jamais de liste noire
|
||||||
|
(une balise/un attribut absent de la liste blanche est TOUJOURS
|
||||||
|
supprimé, y compris un futur ajout du format SVG qu'on n'aurait pas
|
||||||
|
anticipé ici)."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__(convert_charrefs=True)
|
||||||
|
self.output: list[str] = []
|
||||||
|
self._skip_depth = 0
|
||||||
|
|
||||||
|
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
||||||
|
self._open_tag(tag, attrs, self_closing=False)
|
||||||
|
|
||||||
|
def handle_startendtag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
||||||
|
self._open_tag(tag, attrs, self_closing=True)
|
||||||
|
|
||||||
|
def _open_tag(self, tag: str, attrs: list[tuple[str, str | None]], *, self_closing: bool) -> None:
|
||||||
|
tag = tag.lower()
|
||||||
|
if self._skip_depth or tag not in _ALLOWED_TAGS:
|
||||||
|
# Une balise auto-fermante (ex. <script/>) n'aura jamais de
|
||||||
|
# handle_endtag correspondant : incrémenter ici ferait fuir
|
||||||
|
# tout le reste du document dans un skip permanent.
|
||||||
|
if not self_closing:
|
||||||
|
self._skip_depth += 1
|
||||||
|
return
|
||||||
|
kept = [(name.lower(), value) for name, value in attrs if name.lower() in _ALLOWED_ATTRS]
|
||||||
|
attrs_html = "".join(f' {name}="{_escape_attr(value or "")}"' for name, value in kept)
|
||||||
|
self.output.append(f"<{tag}{attrs_html}{'/>' if self_closing else '>'}")
|
||||||
|
|
||||||
|
def handle_endtag(self, tag: str) -> None:
|
||||||
|
if self._skip_depth:
|
||||||
|
self._skip_depth -= 1
|
||||||
|
return
|
||||||
|
if tag.lower() in _ALLOWED_TAGS:
|
||||||
|
self.output.append(f"</{tag.lower()}>")
|
||||||
|
|
||||||
|
def handle_data(self, data: str) -> None:
|
||||||
|
if not self._skip_depth:
|
||||||
|
self.output.append(_escape_text(data))
|
||||||
|
|
||||||
|
|
||||||
|
def _escape_attr(value: str) -> str:
|
||||||
|
return value.replace("&", "&").replace('"', """).replace("<", "<").replace(">", ">")
|
||||||
|
|
||||||
|
|
||||||
|
def _escape_text(value: str) -> str:
|
||||||
|
return value.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_svg_markup(markup: str) -> str:
|
||||||
|
"""Nettoie `markup` selon la liste blanche `_ALLOWED_TAGS`/
|
||||||
|
`_ALLOWED_ATTRS` — toute balise/attribut absent de ces listes (y
|
||||||
|
compris `<script>`, `on*`, `style`, `href`/`xlink:href`,
|
||||||
|
`<foreignObject>`) est supprimé, jamais échappé tel quel.
|
||||||
|
- **Retour** : le fragment SVG nettoyé, sûr à insérer tel quel dans le
|
||||||
|
HTML rendu (jamais vide de sens : une balise inconnue disparaît
|
||||||
|
avec son contenu, une balise connue avec un attribut refusé perd
|
||||||
|
seulement cet attribut).
|
||||||
|
- **Exceptions** : aucune."""
|
||||||
|
sanitizer = _SvgSanitizer()
|
||||||
|
sanitizer.feed(markup)
|
||||||
|
sanitizer.close()
|
||||||
|
return "".join(sanitizer.output)
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
from .securite_incendie_seed import SECURITE_INCENDIE_SEED_PAGES
|
||||||
|
from .seed_blocks_to_elements import seed_blocks_to_elements
|
||||||
|
from .theme_catalog import DOCUMENT_THEMES, get_document_theme_entry
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"DOCUMENT_THEMES",
|
||||||
|
"SECURITE_INCENDIE_SEED_PAGES",
|
||||||
|
"get_document_theme_entry",
|
||||||
|
"seed_blocks_to_elements",
|
||||||
|
]
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
"""Contenu de démonstration du thème "Sécurité Incendie" (voir
|
||||||
|
theme_catalog.py) — vraie curriculum de formation, jamais du texte de
|
||||||
|
remplissage (voir CLAUDE.md, artifact-design : "Build with real content
|
||||||
|
throughout, never lorem"). Un support qui applique ce thème en mode
|
||||||
|
"utiliser le contenu du modèle" (voir routes/document/
|
||||||
|
document_theme_apply.py) reçoit EXACTEMENT ces pages, entièrement
|
||||||
|
modifiables ensuite comme n'importe quel contenu créé à la main."""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
SECURITE_INCENDIE_SEED_PAGES: list[dict[str, Any]] = [
|
||||||
|
# Page 1 — Titre (contenu centré verticalement, comme une page de
|
||||||
|
# garde — retour utilisateur du 24/09/2026)
|
||||||
|
{
|
||||||
|
"vertical_align": "center",
|
||||||
|
"blocks": [
|
||||||
|
{
|
||||||
|
"kind": "badge",
|
||||||
|
"attributes": {
|
||||||
|
"content": "Module obligatoire",
|
||||||
|
"svg_markup": (
|
||||||
|
'<svg viewBox="0 0 24 24" fill="currentColor">'
|
||||||
|
'<path d="M12 2C9 6 6 9 6 13a6 6 0 0 0 12 0c0-2-1-3.5-2-5 '
|
||||||
|
'.3 2-.7 3-1.5 2.3C15.5 9 15 6 12 2z"/></svg>'
|
||||||
|
),
|
||||||
|
"width": "fit-content",
|
||||||
|
"border_radius": "999px",
|
||||||
|
"bold": True,
|
||||||
|
"uppercase": True,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{"kind": "titre", "attributes": {"content": "Formation Sécurité Incendie", "style": "titre1"}},
|
||||||
|
{
|
||||||
|
"kind": "paragraphe",
|
||||||
|
"attributes": {
|
||||||
|
"content": (
|
||||||
|
"Reconnaître les risques, réagir dans les 3 premières minutes, protéger les "
|
||||||
|
"personnes autour de vous. Durée : 25 min · à renouveler tous les 24 mois."
|
||||||
|
),
|
||||||
|
"max_width": "60ch",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
# Page 2 — Objectifs
|
||||||
|
{
|
||||||
|
"blocks": [
|
||||||
|
{"kind": "titre", "attributes": {"content": "À l'issue de ce module, vous saurez", "style": "titre2"}},
|
||||||
|
{
|
||||||
|
"kind": "liste_puces",
|
||||||
|
"attributes": {
|
||||||
|
"items": [
|
||||||
|
"Identifier les causes principales d'un départ de feu sur un poste de travail",
|
||||||
|
"Choisir le bon type d'extincteur selon la classe de feu rencontrée",
|
||||||
|
"Déclencher l'alarme et évacuer en moins de 3 minutes",
|
||||||
|
]
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "badge",
|
||||||
|
"attributes": {
|
||||||
|
"content": (
|
||||||
|
"Un feu peut doubler de volume toutes les 30 secondes — la rapidité de "
|
||||||
|
"réaction compte autant que le geste."
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
# Page 3 — Classes de feu
|
||||||
|
{
|
||||||
|
"blocks": [
|
||||||
|
{"kind": "titre", "attributes": {"content": "Les 5 classes de feu", "style": "titre2"}},
|
||||||
|
{
|
||||||
|
"kind": "paragraphe",
|
||||||
|
"attributes": {
|
||||||
|
"content": (
|
||||||
|
"Chaque classe correspond à un combustible différent — le mauvais "
|
||||||
|
"extincteur peut aggraver l'incendie."
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "row",
|
||||||
|
"attributes": {"gap": 10, "align": "stretch", "justify": "flex-start"},
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"kind": "carte",
|
||||||
|
"attributes": {"label": "A", "title": "Feux secs", "description": "Bois, papier, tissus"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "carte",
|
||||||
|
"attributes": {"label": "B", "title": "Liquides", "description": "Essence, solvants"},
|
||||||
|
},
|
||||||
|
{"kind": "carte", "attributes": {"label": "C", "title": "Gaz", "description": "Butane, propane"}},
|
||||||
|
{
|
||||||
|
"kind": "carte",
|
||||||
|
"attributes": {"label": "D", "title": "Métaux", "description": "Sodium, magnésium"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "carte",
|
||||||
|
"attributes": {"label": "F", "title": "Huiles", "description": "Friteuses, graisses"},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "badge",
|
||||||
|
"attributes": {
|
||||||
|
"content": (
|
||||||
|
"⚠ Un feu électrique n'est pas une classe à part : coupez toujours le "
|
||||||
|
"courant avant d'intervenir."
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
# Page 4 — Méthode P.A.S.S.
|
||||||
|
{
|
||||||
|
"blocks": [
|
||||||
|
{
|
||||||
|
"kind": "titre",
|
||||||
|
"attributes": {"content": "Utiliser un extincteur : la méthode P.A.S.S.", "style": "titre2"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "liste_numerotee",
|
||||||
|
"attributes": {
|
||||||
|
"items": [
|
||||||
|
"Percuter — retirez la goupille de sécurité",
|
||||||
|
"Approcher — à 2 ou 3 mètres du foyer",
|
||||||
|
"Serrer — pressions courtes, pas en continu",
|
||||||
|
"Balayer — la base des flammes, gauche à droite",
|
||||||
|
]
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "row",
|
||||||
|
"attributes": {"gap": 12, "align": "center", "justify": "space-between"},
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"kind": "badge",
|
||||||
|
"attributes": {"content": "⚠ Jamais d'eau sur un feu électrique ou une friteuse en feu."},
|
||||||
|
},
|
||||||
|
{"kind": "bouton", "attributes": {"label": "Fiche consignes", "target": ""}},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
# Page 5 — Évacuation
|
||||||
|
{
|
||||||
|
"blocks": [
|
||||||
|
{"kind": "titre", "attributes": {"content": "Consignes d'évacuation", "style": "titre2"}},
|
||||||
|
{
|
||||||
|
"kind": "paragraphe",
|
||||||
|
"attributes": {"content": "Dès que l'alarme retentit, l'évacuation est immédiate — aucune exception."},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "liste_numerotee",
|
||||||
|
"attributes": {
|
||||||
|
"items": [
|
||||||
|
"Cessez toute activité, laissez vos affaires sur place",
|
||||||
|
"Suivez le fléchage vers la sortie la plus proche, jamais l'ascenseur",
|
||||||
|
"Rejoignez le point de rassemblement, attendez l'appel de votre nom",
|
||||||
|
"Ne retournez jamais à l'intérieur avant le signal du responsable",
|
||||||
|
]
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
# Page 6 — Quiz (SEUL sur sa page : règle du moteur, voir
|
||||||
|
# routes/document/document_element_add.py — respectée ici dès la
|
||||||
|
# conception du contenu-seed, jamais contournée).
|
||||||
|
{
|
||||||
|
"blocks": [
|
||||||
|
{
|
||||||
|
"kind": "quiz",
|
||||||
|
"attributes": {
|
||||||
|
"theme_color": "#c8102e",
|
||||||
|
"timer_enabled": False,
|
||||||
|
"timer_seconds": 30,
|
||||||
|
"questions": [
|
||||||
|
{
|
||||||
|
"text": (
|
||||||
|
"Quelle classe de feu concerne les liquides inflammables comme "
|
||||||
|
"l'essence ou les solvants ?"
|
||||||
|
),
|
||||||
|
"choices": ["Classe A", "Classe B", "Classe D"],
|
||||||
|
"correct_index": 1,
|
||||||
|
"points": 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Quel est le premier geste de la méthode P.A.S.S. ?",
|
||||||
|
"choices": [
|
||||||
|
"Balayer la base des flammes",
|
||||||
|
"Percuter (retirer la goupille)",
|
||||||
|
"Approcher à 1 mètre",
|
||||||
|
],
|
||||||
|
"correct_index": 1,
|
||||||
|
"points": 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Que faire dès que l'alarme incendie retentit ?",
|
||||||
|
"choices": [
|
||||||
|
"Terminer sa tâche puis sortir",
|
||||||
|
"Prendre l'ascenseur pour aller plus vite",
|
||||||
|
"Évacuer immédiatement par les issues de secours",
|
||||||
|
],
|
||||||
|
"correct_index": 2,
|
||||||
|
"points": 10,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from ..labels.element_kind_labels import element_default_attributes
|
||||||
|
|
||||||
|
|
||||||
|
def seed_blocks_to_elements(blocks: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
"""Convertit une liste de blocs de contenu-seed (voir theme_catalog.py
|
||||||
|
— `seed_pages`) en une liste d'éléments "à plat" directement
|
||||||
|
exploitable par `render_document_element.render_document` — ids
|
||||||
|
synthétiques négatifs, JAMAIS persistés (aperçu d'un thème
|
||||||
|
uniquement, voir routes/document/document_theme_preview.py ; pour la
|
||||||
|
persistance réelle voir document_engine.replace_document_content,
|
||||||
|
qui ne réutilise pas cette fonction — elle a besoin de vrais ids
|
||||||
|
attribués par la base au fil des insertions)."""
|
||||||
|
elements: list[dict[str, Any]] = []
|
||||||
|
next_id = -1
|
||||||
|
for block in blocks:
|
||||||
|
next_id = _add_block(elements, block, parent_id=None, next_id=next_id)
|
||||||
|
return elements
|
||||||
|
|
||||||
|
|
||||||
|
def _add_block(elements: list[dict[str, Any]], block: dict[str, Any], parent_id: int | None, next_id: int) -> int:
|
||||||
|
element_id = next_id
|
||||||
|
next_id -= 1
|
||||||
|
kind = block["kind"]
|
||||||
|
attributes = {**element_default_attributes(kind), **block.get("attributes", {})}
|
||||||
|
elements.append({"id": element_id, "kind": kind, "parent_id": parent_id, "attributes": attributes})
|
||||||
|
for child in block.get("children", []):
|
||||||
|
next_id = _add_block(elements, child, parent_id=element_id, next_id=next_id)
|
||||||
|
return next_id
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""Catalogue des thèmes visuels applicables à un support (voir consigne
|
||||||
|
du 24/09/2026 : le moteur ne porte QUE contenu et mécanisme — chaque
|
||||||
|
thème est une feuille de style externe (`css_path`, servie telle quelle
|
||||||
|
depuis static/) qui habille les mêmes classes fixes du moteur
|
||||||
|
(.docPage/.docText/.docList/.docCard/.docBadge/.docMinigame/...), jamais
|
||||||
|
du code Python qui en changerait la structure."""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from .securite_incendie_seed import SECURITE_INCENDIE_SEED_PAGES
|
||||||
|
|
||||||
|
DOCUMENT_THEMES: list[dict[str, Any]] = [
|
||||||
|
{
|
||||||
|
"id": "securite-incendie",
|
||||||
|
"name": "Sécurité Incendie",
|
||||||
|
"category": "Prévention & sécurité",
|
||||||
|
"description": "Rouge sécurité et ambre balisage, typographie signalétique — pour une formation réglementaire.",
|
||||||
|
"css_path": "document/themes/securite-incendie.css",
|
||||||
|
"font_url": (
|
||||||
|
"https://fonts.googleapis.com/css2?"
|
||||||
|
"family=Oswald:wght@500;600;700&family=Source+Sans+3:wght@400;500;600;700&display=swap"
|
||||||
|
),
|
||||||
|
"seed_pages": SECURITE_INCENDIE_SEED_PAGES,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def get_document_theme_entry(theme_id: str) -> dict[str, Any] | None:
|
||||||
|
"""- **Retour** : l'entrée du catalogue dont `id == theme_id`, ou
|
||||||
|
`None` si aucun thème de ce catalogue ne porte cet id.
|
||||||
|
- **Exceptions** : aucune."""
|
||||||
|
return next((theme for theme in DOCUMENT_THEMES if theme["id"] == theme_id), None)
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# document_engine/themes/
|
||||||
|
|
||||||
|
Catalogue des thèmes visuels applicables à un support (bouton "Utiliser
|
||||||
|
un modèle" à côté d'Aperçu, voir `templates/document/document_edit.html`
|
||||||
|
et `static/document/js/document-editor.js`). Décision du 24/09/2026 : le
|
||||||
|
moteur ne porte QUE contenu et mécanisme — chaque thème est une feuille
|
||||||
|
de style externe (`static/document/themes/<id>.css`, servie telle quelle)
|
||||||
|
qui habille les classes FIXES du moteur (`.docPage`/`.docText`/
|
||||||
|
`.docList`/`.docCard`/`.docBadge`/`.docButton`/`.docImage`/
|
||||||
|
`.docMinigame`/...), jamais du code qui en changerait la structure. Une
|
||||||
|
centaine de thèmes est prévue à terme : ce découpage (données de
|
||||||
|
catalogue + CSS statique, aucun code Python par thème au-delà d'une
|
||||||
|
entrée de catalogue) est pensé pour rester gérable à cette échelle.
|
||||||
|
|
||||||
|
## `DOCUMENT_THEMES: list[dict[str, Any]]`
|
||||||
|
Un dict par thème : `id` (identifiant stable, utilisé dans les URLs et
|
||||||
|
persisté via `db.set_document_theme`), `name`, `category`, `description`
|
||||||
|
(affichage dans la modale), `css_path` (chemin sous `static/`, passé à
|
||||||
|
`url_for('static', filename=...)`), `font_url` (optionnel, lien Google
|
||||||
|
Fonts), `seed_pages` (contenu de démonstration — une liste de dicts
|
||||||
|
`{"vertical_align": "top"|"center"|"bottom", "blocks": [...]}`, voir
|
||||||
|
`document_engine.replace_document_content` pour la forme exacte de
|
||||||
|
`blocks`). L'auteur d'un thème est responsable de respecter les règles
|
||||||
|
structurelles du moteur dans son `seed_pages` (ex. un mini-jeu seul sur
|
||||||
|
sa page — voir `routes/document/document_element_add.py` — jamais
|
||||||
|
revérifié automatiquement puisque ce contenu vient du thème, pas de
|
||||||
|
l'utilisateur ; voir `tests/document/test_document_themes.py` pour la
|
||||||
|
vérification statique de cette règle sur tout le catalogue).
|
||||||
|
|
||||||
|
## `get_document_theme_entry(theme_id: str) -> dict[str, Any] | None`
|
||||||
|
- **Retour** : l'entrée de `DOCUMENT_THEMES` dont `id == theme_id`, ou
|
||||||
|
`None` si aucun thème de ce catalogue ne porte cet id.
|
||||||
|
- **Exceptions** : aucune.
|
||||||
|
|
||||||
|
## `seed_blocks_to_elements(blocks: list[dict[str, Any]]) -> list[dict[str, Any]]`
|
||||||
|
Convertit une liste de blocs de contenu-seed (`seed_pages[i]["blocks"]`)
|
||||||
|
en une liste d'éléments "à plat" (id/kind/parent_id/attributes)
|
||||||
|
directement
|
||||||
|
exploitable par `document_engine.render_document` — ids synthétiques
|
||||||
|
NÉGATIFS, jamais persistés. Utilisée UNIQUEMENT pour l'aperçu d'un thème
|
||||||
|
(voir `routes/document/document_theme_preview.py`, destiné à un
|
||||||
|
`<iframe>` dans la modale) : ce qui est prévisualisé est ainsi
|
||||||
|
RÉELLEMENT rendu par le moteur, jamais une image statique ni une
|
||||||
|
resucée manuelle du CSS. Pour la persistance réelle du contenu, voir
|
||||||
|
`document_engine.replace_document_content` — qui ne réutilise pas cette
|
||||||
|
fonction, ayant besoin de vrais ids attribués par la base au fil des
|
||||||
|
insertions.
|
||||||
|
- **Retour** : liste d'éléments prête pour `render_document`.
|
||||||
|
- **Exceptions** : aucune.
|
||||||
|
|
||||||
|
## `securite_incendie_seed.py` — contenu du premier thème implémenté
|
||||||
|
|
||||||
|
`SECURITE_INCENDIE_SEED_PAGES` : vrai contenu de formation (6 pages —
|
||||||
|
titre, objectifs, classes de feu, méthode P.A.S.S., évacuation, quiz de
|
||||||
|
validation à 3 questions), jamais du texte de remplissage. Sert à la
|
||||||
|
fois de contenu par défaut ("utiliser le contenu du modèle") et de
|
||||||
|
première validation bout-en-bout du mécanisme de thème.
|
||||||
@@ -8,8 +8,21 @@ from . import ( # noqa: F401 - enregistre les routes definies dans chaque modul
|
|||||||
document_edit,
|
document_edit,
|
||||||
document_element_add,
|
document_element_add,
|
||||||
document_element_delete,
|
document_element_delete,
|
||||||
|
document_element_download_attachment,
|
||||||
document_element_move,
|
document_element_move,
|
||||||
|
document_element_move_to_page,
|
||||||
document_element_update,
|
document_element_update,
|
||||||
|
document_element_upload_attachment,
|
||||||
|
document_element_upload_image,
|
||||||
document_new,
|
document_new,
|
||||||
|
document_page_add,
|
||||||
|
document_page_delete,
|
||||||
|
document_page_delete_all,
|
||||||
|
document_page_move,
|
||||||
|
document_page_rename,
|
||||||
|
document_page_vertical_align,
|
||||||
document_render,
|
document_render,
|
||||||
|
document_theme_apply,
|
||||||
|
document_theme_preview,
|
||||||
|
document_uploaded_file,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
from flask import render_template
|
from flask import render_template
|
||||||
|
|
||||||
import db
|
import db
|
||||||
@@ -11,14 +13,69 @@ def document_edit(slug: str) -> str:
|
|||||||
support = un projet = un document, voir docs/plan/PLAN.md). Contraste
|
support = un projet = un document, voir docs/plan/PLAN.md). Contraste
|
||||||
avec l'environnement 2D (clic sur une carte -> game_dashboard qui liste
|
avec l'environnement 2D (clic sur une carte -> game_dashboard qui liste
|
||||||
ses écrans -> éditeur de scène) : ici la carte "Mes formations" mène
|
ses écrans -> éditeur de scène) : ici la carte "Mes formations" mène
|
||||||
directement ici."""
|
directement ici.
|
||||||
|
|
||||||
|
Un support est composé de plusieurs PAGES (voir document_engine/
|
||||||
|
pages/, retour utilisateur du 21/09/2026 : "il faut implémenter un
|
||||||
|
système de page") : le canevas n'affiche au chargement que la
|
||||||
|
PREMIÈRE page (triée par order_index) — changer de page se fait
|
||||||
|
ensuite entièrement côté client via /document/<slug>/render?page_id=
|
||||||
|
(voir static/document/js/document-editor.js), jamais un rechargement
|
||||||
|
complet de cette route. La navigation entre pages (ajout/renommage/
|
||||||
|
suppression/réordonnancement) vit dans une section dédiée du panneau
|
||||||
|
gauche (retour utilisateur du 21/09/2026 : "une section qui s'ajoute
|
||||||
|
dans le panneau de gauche pour ajouter une page et naviguer entre
|
||||||
|
elles"), qui n'a besoin que des métadonnées de page (`pages`), jamais
|
||||||
|
d'un rendu de leur contenu.
|
||||||
|
|
||||||
|
Les attributs de chaque élément sont revalidés (document_engine.
|
||||||
|
sanitize_element_attributes) avant d'atteindre le client — jamais les
|
||||||
|
valeurs brutes stockées telles quelles : un élément dont le schéma a
|
||||||
|
évolué depuis sa création (ex. le mini-jeu Scénario, passé d'une liste
|
||||||
|
plate à un arbre de décision) ferait sinon planter silencieusement le
|
||||||
|
panneau Propriétés côté client, qui suppose la forme ACTUELLE (bug réel
|
||||||
|
constaté le 21/09/2026)."""
|
||||||
support = db.support_meta(slug)
|
support = db.support_meta(slug)
|
||||||
elements = document_engine.list_document_elements(slug)
|
pages = document_engine.list_document_pages(slug)
|
||||||
|
# active_page peut être None : un support peut avoir 0 page (retour
|
||||||
|
# utilisateur du 26/09/2026, voir document_engine/pages/pages.md) — le
|
||||||
|
# canevas et le panneau Pages doivent alors afficher un état "aucune
|
||||||
|
# page" plutôt que de planter, voir document_edit.html et
|
||||||
|
# document-editor.js (forgeDocSwitchPage/forgeDocRefreshCanvas).
|
||||||
|
active_page = pages[0] if pages else None
|
||||||
|
|
||||||
|
active_elements: list[dict[str, Any]] = (
|
||||||
|
[
|
||||||
|
{**el, "attributes": document_engine.sanitize_element_attributes(el["kind"], el["attributes"])}
|
||||||
|
for el in document_engine.list_document_elements(slug, active_page["id"])
|
||||||
|
]
|
||||||
|
if active_page is not None
|
||||||
|
else []
|
||||||
|
)
|
||||||
|
|
||||||
|
active_theme = document_engine.get_document_theme_entry(support["theme"]) if support["theme"] else None
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
"document/document_edit.html",
|
"document/document_edit.html",
|
||||||
support=support,
|
support=support,
|
||||||
elements=elements,
|
pages=pages,
|
||||||
rendered_document=document_engine.render_document(elements),
|
active_page=active_page,
|
||||||
|
elements=active_elements,
|
||||||
|
rendered_document=document_engine.render_document(active_elements),
|
||||||
element_library=document_engine.ELEMENT_LIBRARY,
|
element_library=document_engine.ELEMENT_LIBRARY,
|
||||||
element_kind_labels=document_engine.ELEMENT_KIND_LABELS,
|
element_kind_labels=document_engine.ELEMENT_KIND_LABELS,
|
||||||
|
active_theme=active_theme,
|
||||||
|
# Seuls les champs utiles à la modale "Utiliser un modèle" côté
|
||||||
|
# client (voir static/document/js/document-editor.js) — jamais le
|
||||||
|
# `seed_pages` complet, inutilement volumineux et non nécessaire
|
||||||
|
# côté client (l'aperçu et l'application se font tous deux en
|
||||||
|
# appelant le serveur, voir document_theme_preview.py/
|
||||||
|
# document_theme_apply.py). Pas de route JSON dédiée pour une
|
||||||
|
# donnée 100% statique côté serveur : le catalogue tient déjà
|
||||||
|
# dans le contexte de cette page (voir document_engine/themes/
|
||||||
|
# theme_catalog.py).
|
||||||
|
document_themes=[
|
||||||
|
{"id": t["id"], "name": t["name"], "category": t["category"], "description": t["description"]}
|
||||||
|
for t in document_engine.DOCUMENT_THEMES
|
||||||
|
],
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -10,25 +10,45 @@ from core.flask_app import app
|
|||||||
|
|
||||||
@app.route("/document/<slug>/elements/add", methods=["POST"])
|
@app.route("/document/<slug>/elements/add", methods=["POST"])
|
||||||
def document_element_add(slug: str) -> Response | tuple[Response, int]:
|
def document_element_add(slug: str) -> Response | tuple[Response, int]:
|
||||||
"""Ajoute un élément — appelée en AJAX depuis la bibliothèque du
|
"""Ajoute un élément à une page précise — appelée en AJAX depuis la
|
||||||
panneau gauche (clic ou glisser-déposer initial), OU par le moteur de
|
bibliothèque du panneau gauche (clic ou glisser-déposer initial), OU
|
||||||
layout lui-même pour créer une rangée à la volée au moment d'un dépôt
|
par le moteur de layout lui-même pour créer une rangée à la volée au
|
||||||
latéral (kind="row" — jamais choisi directement dans la bibliothèque,
|
moment d'un dépôt latéral (kind="row" — jamais choisi directement
|
||||||
absent de document_engine.ELEMENT_LIBRARY, mais un kind valide comme
|
dans la bibliothèque, absent de document_engine.ELEMENT_LIBRARY, mais
|
||||||
un autre pour cette route ; voir static/document/js/document-editor.js).
|
un kind valide comme un autre pour cette route ; voir
|
||||||
parent_id, quand fourni, place l'élément directement dans une rangée
|
static/document/js/document-editor.js). parent_id, quand fourni,
|
||||||
existante."""
|
place l'élément directement dans une rangée existante DE CETTE MÊME
|
||||||
|
PAGE."""
|
||||||
kind = request.form.get("kind", "")
|
kind = request.form.get("kind", "")
|
||||||
if kind not in document_engine.ELEMENT_KIND_LABELS:
|
if kind not in document_engine.ELEMENT_KIND_LABELS:
|
||||||
return jsonify({"error": "type d'élément inconnu"}), 400
|
return jsonify({"error": "type d'élément inconnu"}), 400
|
||||||
|
page_id = request.form.get("page_id", type=int)
|
||||||
|
if page_id is None or document_engine.get_document_page(slug, page_id) is None:
|
||||||
|
return jsonify({"error": "page introuvable"}), 404
|
||||||
parent_id = request.form.get("parent_id", type=int)
|
parent_id = request.form.get("parent_id", type=int)
|
||||||
element_id = document_engine.add_document_element(slug, kind, parent_id=parent_id)
|
|
||||||
|
# Un mini-jeu occupe toute la page, à lui seul (retour utilisateur du
|
||||||
|
# 23/09/2026 : "un mini jeu dois occupper toute une page" -> "une page
|
||||||
|
# avec mini-jeu = uniquement ce mini-jeu"). Vérifié ici, POINT D'ENTRÉE
|
||||||
|
# UNIQUE de tout ajout d'élément (bibliothèque, glisser-déposer,
|
||||||
|
# création de rangée à la volée, Annuler/Rétablir) : jamais dupliqué
|
||||||
|
# côté client, qui se contente d'afficher l'erreur renvoyée.
|
||||||
|
is_minigame = kind in document_engine.MINIGAME_KINDS
|
||||||
|
existing = document_engine.list_document_elements(slug, page_id)
|
||||||
|
if is_minigame and parent_id is not None:
|
||||||
|
return jsonify({"error": "Un mini-jeu ne peut pas être placé dans une rangée."}), 400
|
||||||
|
if is_minigame and existing:
|
||||||
|
return jsonify({"error": "Un mini-jeu doit être seul sur sa page — ajoutez-le sur une nouvelle page."}), 400
|
||||||
|
if not is_minigame and any(el["kind"] in document_engine.MINIGAME_KINDS for el in existing):
|
||||||
|
return jsonify({"error": "Cette page contient déjà un mini-jeu qui occupe toute la page."}), 400
|
||||||
|
|
||||||
|
element_id = document_engine.add_document_element(slug, kind, page_id=page_id, parent_id=parent_id)
|
||||||
element = db.assert_not_none(
|
element = db.assert_not_none(
|
||||||
document_engine.get_document_element(slug, element_id),
|
document_engine.get_document_element(slug, element_id),
|
||||||
"element_id vient d'etre cree par add_document_element juste au-dessus",
|
"element_id vient d'etre cree par add_document_element juste au-dessus",
|
||||||
)
|
)
|
||||||
elements_by_parent: dict[int | None, list[dict[str, Any]]] = {}
|
elements_by_parent: dict[int | None, list[dict[str, Any]]] = {}
|
||||||
for el in document_engine.list_document_elements(slug):
|
for el in document_engine.list_document_elements(slug, page_id):
|
||||||
elements_by_parent.setdefault(el["parent_id"], []).append(el)
|
elements_by_parent.setdefault(el["parent_id"], []).append(el)
|
||||||
return jsonify(
|
return jsonify(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
from flask import send_from_directory
|
||||||
|
from werkzeug.exceptions import NotFound
|
||||||
|
from werkzeug.wrappers import Response
|
||||||
|
|
||||||
|
import db
|
||||||
|
import document_engine
|
||||||
|
from core.flask_app import app
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/document/<slug>/elements/<int:element_id>/download-attachment")
|
||||||
|
def document_element_download_attachment(slug: str, element_id: int) -> Response:
|
||||||
|
"""Sert le fichier joint à un bouton (voir
|
||||||
|
document_element_upload_attachment.py) sous son nom d'origine
|
||||||
|
(`download_name`), jamais sous son nom de stockage anonymisé
|
||||||
|
(`attachment_stored_name`, un UUID)."""
|
||||||
|
element = document_engine.get_document_element(slug, element_id)
|
||||||
|
if element is None:
|
||||||
|
raise NotFound
|
||||||
|
stored_name = str(element["attributes"].get("attachment_stored_name", ""))
|
||||||
|
original_filename = str(element["attributes"].get("attachment_filename", ""))
|
||||||
|
if not stored_name:
|
||||||
|
raise NotFound
|
||||||
|
return send_from_directory(
|
||||||
|
os.path.join(db.support_dir(slug), "attachments"),
|
||||||
|
stored_name,
|
||||||
|
as_attachment=True,
|
||||||
|
download_name=original_filename or stored_name,
|
||||||
|
)
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
from flask import jsonify, request
|
||||||
|
from werkzeug.wrappers import Response
|
||||||
|
|
||||||
|
import document_engine
|
||||||
|
from core.flask_app import app
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/document/<slug>/elements/<int:element_id>/move-to-page", methods=["POST"])
|
||||||
|
def document_element_move_to_page(slug: str, element_id: int) -> Response | tuple[Response, int]:
|
||||||
|
"""Déplace un élément vers une AUTRE page du support — appelée par la
|
||||||
|
pagination automatique côté client (retour utilisateur du 23/09/2026 :
|
||||||
|
"si il n'y a plus de place sur la page il faut automatiquement créer
|
||||||
|
une autre page [et y] coller le contenu", voir
|
||||||
|
static/document/js/document-editor.js, forgeDocCheckPageOverflow),
|
||||||
|
jamais par un glisser-déposer manuel (qui reste sur
|
||||||
|
document_element_move, réordonnancement dans la MÊME page)."""
|
||||||
|
payload = request.get_json(force=True) or {}
|
||||||
|
target_page_id = payload.get("target_page_id")
|
||||||
|
if not isinstance(target_page_id, int) or isinstance(target_page_id, bool):
|
||||||
|
return jsonify({"error": "page cible introuvable"}), 404
|
||||||
|
if document_engine.get_document_page(slug, target_page_id) is None:
|
||||||
|
return jsonify({"error": "page cible introuvable"}), 404
|
||||||
|
document_engine.move_document_element_to_page(slug, element_id, target_page_id)
|
||||||
|
return jsonify({"ok": True})
|
||||||
@@ -14,26 +14,30 @@ def document_element_update(slug: str, element_id: int) -> Response | tuple[Resp
|
|||||||
Propriétés (voir static/document/js/document-editor.js) envoie
|
Propriétés (voir static/document/js/document-editor.js) envoie
|
||||||
systématiquement l'état complet de ses champs.
|
systématiquement l'état complet de ses champs.
|
||||||
|
|
||||||
Un quiz est le seul kind revalidé côté serveur (sanitize_quiz_config,
|
Quiz, Association, Memory, Mots mêlés et Scénario sont les seuls
|
||||||
|
kinds revalidés côté serveur (document_engine.sanitize_element_attributes,
|
||||||
même convention que game_engine/rendering/quiz_box_config.py côté
|
même convention que game_engine/rendering/quiz_box_config.py côté
|
||||||
jeu) : sa forme (liste de questions/choix/index de bonne réponse) doit
|
jeu) : leur forme (liste de questions/choix, liste de paires, liste
|
||||||
rester structurellement correcte pour que le rendu ne plante jamais,
|
de cartes, liste de mots, arbre de scénarios) doit rester
|
||||||
|
structurellement correcte pour que le rendu ne plante jamais,
|
||||||
contrairement aux autres kinds (texte/forme/image...) dont les
|
contrairement aux autres kinds (texte/forme/image...) dont les
|
||||||
attributs sont de simples valeurs scalaires sans structure à
|
attributs sont de simples valeurs scalaires sans structure à
|
||||||
garantir."""
|
garantir. Même sanitizer utilisé en LECTURE par document_edit.py/
|
||||||
|
document_render.py — un élément dont les attributs stockés datent
|
||||||
|
d'un schéma devenu obsolète est ainsi normalisé partout, jamais
|
||||||
|
seulement à l'écriture."""
|
||||||
element = document_engine.get_document_element(slug, element_id)
|
element = document_engine.get_document_element(slug, element_id)
|
||||||
if element is None:
|
if element is None:
|
||||||
return jsonify({"error": "élément introuvable"}), 404
|
return jsonify({"error": "élément introuvable"}), 404
|
||||||
attributes: dict[str, Any] = request.get_json(force=True) or {}
|
attributes: dict[str, Any] = request.get_json(force=True) or {}
|
||||||
if element["kind"] == "quiz":
|
attributes = document_engine.sanitize_element_attributes(element["kind"], attributes)
|
||||||
attributes = document_engine.sanitize_quiz_config(attributes)
|
|
||||||
document_engine.update_document_element_attributes(slug, element_id, attributes)
|
document_engine.update_document_element_attributes(slug, element_id, attributes)
|
||||||
element = db.assert_not_none(
|
element = db.assert_not_none(
|
||||||
document_engine.get_document_element(slug, element_id),
|
document_engine.get_document_element(slug, element_id),
|
||||||
"element_id verifie present juste au-dessus, aucune suppression concurrente possible entre-temps ici",
|
"element_id verifie present juste au-dessus, aucune suppression concurrente possible entre-temps ici",
|
||||||
)
|
)
|
||||||
elements_by_parent: dict[int | None, list[dict[str, Any]]] = {}
|
elements_by_parent: dict[int | None, list[dict[str, Any]]] = {}
|
||||||
for el in document_engine.list_document_elements(slug):
|
for el in document_engine.list_document_elements(slug, element["page_id"]):
|
||||||
elements_by_parent.setdefault(el["parent_id"], []).append(el)
|
elements_by_parent.setdefault(el["parent_id"], []).append(el)
|
||||||
return jsonify(
|
return jsonify(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from flask import jsonify, request
|
||||||
|
from werkzeug.wrappers import Response
|
||||||
|
|
||||||
|
import db
|
||||||
|
import document_engine
|
||||||
|
from core.flask_app import app
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/document/<slug>/elements/<int:element_id>/upload-attachment", methods=["POST"])
|
||||||
|
def document_element_upload_attachment(slug: str, element_id: int) -> Response | tuple[Response, int]:
|
||||||
|
"""Joint un fichier téléchargeable à un bouton (voir _render_button,
|
||||||
|
document_engine/rendering/render_document_element.py) — mirroir de
|
||||||
|
routes/uploads/upload_file.py côté jeu, mais stocké sous le dossier du
|
||||||
|
SUPPORT (db.support_dir) et réservé au kind "bouton" (un fichier joint
|
||||||
|
n'a de sens que pour déclencher un téléchargement au clic)."""
|
||||||
|
element = document_engine.get_document_element(slug, element_id)
|
||||||
|
if element is None:
|
||||||
|
return jsonify({"error": "élément introuvable"}), 404
|
||||||
|
if element["kind"] != "bouton":
|
||||||
|
return jsonify({"error": "seul un bouton peut recevoir un fichier joint"}), 400
|
||||||
|
f = request.files.get("file")
|
||||||
|
if not f or not f.filename:
|
||||||
|
return jsonify({"error": "Aucun fichier reçu"}), 400
|
||||||
|
original_filename = f.filename
|
||||||
|
ext = "".join(c for c in os.path.splitext(original_filename)[1].lower() if c.isalnum() or c == ".")[:10]
|
||||||
|
stored_name = uuid.uuid4().hex + ext
|
||||||
|
attachments_dir = os.path.join(db.support_dir(slug), "attachments")
|
||||||
|
os.makedirs(attachments_dir, exist_ok=True)
|
||||||
|
f.save(os.path.join(attachments_dir, stored_name))
|
||||||
|
|
||||||
|
attributes = {
|
||||||
|
**element["attributes"],
|
||||||
|
"attachment_stored_name": stored_name,
|
||||||
|
"attachment_filename": original_filename,
|
||||||
|
}
|
||||||
|
document_engine.update_document_element_attributes(slug, element_id, attributes)
|
||||||
|
element = db.assert_not_none(
|
||||||
|
document_engine.get_document_element(slug, element_id),
|
||||||
|
"element_id verifie present juste au-dessus, aucune suppression concurrente possible entre-temps ici",
|
||||||
|
)
|
||||||
|
elements_by_parent: dict[int | None, list[dict[str, Any]]] = {}
|
||||||
|
for el in document_engine.list_document_elements(slug, element["page_id"]):
|
||||||
|
elements_by_parent.setdefault(el["parent_id"], []).append(el)
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"ok": True,
|
||||||
|
"attributes": element["attributes"],
|
||||||
|
"rendered_html": document_engine.render_document_element(element, elements_by_parent),
|
||||||
|
}
|
||||||
|
)
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from flask import jsonify, request, url_for
|
||||||
|
from werkzeug.wrappers import Response
|
||||||
|
|
||||||
|
import db
|
||||||
|
import document_engine
|
||||||
|
from core.flask_app import app
|
||||||
|
|
||||||
|
_ALLOWED_IMAGE_EXTENSIONS = (".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg")
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/document/<slug>/elements/<int:element_id>/upload-image", methods=["POST"])
|
||||||
|
def document_element_upload_image(slug: str, element_id: int) -> Response | tuple[Response, int]:
|
||||||
|
"""Téléverse un fichier image pour un élément "image" (voir
|
||||||
|
_render_image, document_engine/rendering/render_document_element.py)
|
||||||
|
— mirroir de document_element_upload_attachment.py, mais stocké sous
|
||||||
|
`uploads/` (jamais `attachments/`, réservé au bouton) et réservé au
|
||||||
|
kind "image". `svg_markup` est vidé au passage : un fichier
|
||||||
|
téléversé implique `src`, jamais les deux modes en même temps (voir
|
||||||
|
element_default_attributes, `svg_markup` prioritaire sur `src` au
|
||||||
|
rendu — le vider ici évite qu'un ancien code SVG masque
|
||||||
|
silencieusement la photo qui vient d'être choisie)."""
|
||||||
|
element = document_engine.get_document_element(slug, element_id)
|
||||||
|
if element is None:
|
||||||
|
return jsonify({"error": "élément introuvable"}), 404
|
||||||
|
if element["kind"] != "image":
|
||||||
|
return jsonify({"error": "seul un élément image peut recevoir un fichier téléversé"}), 400
|
||||||
|
f = request.files.get("file")
|
||||||
|
if not f or not f.filename:
|
||||||
|
return jsonify({"error": "Aucun fichier reçu"}), 400
|
||||||
|
ext = os.path.splitext(f.filename)[1].lower()
|
||||||
|
if ext not in _ALLOWED_IMAGE_EXTENSIONS:
|
||||||
|
return jsonify({"error": "Format d'image non pris en charge (PNG, JPG, GIF, WEBP, SVG uniquement)"}), 400
|
||||||
|
stored_name = uuid.uuid4().hex + ext
|
||||||
|
uploads_dir = os.path.join(db.support_dir(slug), "uploads")
|
||||||
|
os.makedirs(uploads_dir, exist_ok=True)
|
||||||
|
f.save(os.path.join(uploads_dir, stored_name))
|
||||||
|
|
||||||
|
attributes = {
|
||||||
|
**element["attributes"],
|
||||||
|
"src": url_for("document_uploaded_file", slug=slug, filename=stored_name),
|
||||||
|
"svg_markup": "",
|
||||||
|
}
|
||||||
|
document_engine.update_document_element_attributes(slug, element_id, attributes)
|
||||||
|
element = db.assert_not_none(
|
||||||
|
document_engine.get_document_element(slug, element_id),
|
||||||
|
"element_id verifie present juste au-dessus, aucune suppression concurrente possible entre-temps ici",
|
||||||
|
)
|
||||||
|
elements_by_parent: dict[int | None, list[dict[str, Any]]] = {}
|
||||||
|
for el in document_engine.list_document_elements(slug, element["page_id"]):
|
||||||
|
elements_by_parent.setdefault(el["parent_id"], []).append(el)
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"ok": True,
|
||||||
|
"attributes": element["attributes"],
|
||||||
|
"rendered_html": document_engine.render_document_element(element, elements_by_parent),
|
||||||
|
}
|
||||||
|
)
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
from flask import jsonify, request
|
||||||
|
from werkzeug.wrappers import Response
|
||||||
|
|
||||||
|
import document_engine
|
||||||
|
from core.flask_app import app
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/document/<slug>/pages/add", methods=["POST"])
|
||||||
|
def document_page_add(slug: str) -> Response:
|
||||||
|
"""Ajoute une page en fin de la bande d'onglets — appelée en AJAX
|
||||||
|
depuis le "+" de static/document/js/document-editor.js. Le client
|
||||||
|
bascule ensuite lui-même la page active sur cette nouvelle page (via
|
||||||
|
/document/<slug>/render?page_id=<id>), jamais un rendu HTML renvoyé
|
||||||
|
ici : une page neuve n'a aucun élément à rendre."""
|
||||||
|
payload = request.get_json(silent=True) or {}
|
||||||
|
title = payload.get("title")
|
||||||
|
page_id = document_engine.add_document_page(slug, title=title)
|
||||||
|
page = document_engine.get_document_page(slug, page_id)
|
||||||
|
return jsonify({"id": page_id, "title": page["title"] if page else title})
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
from flask import jsonify
|
||||||
|
from werkzeug.wrappers import Response
|
||||||
|
|
||||||
|
import document_engine
|
||||||
|
from core.flask_app import app
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/document/<slug>/pages/<int:page_id>/delete", methods=["POST"])
|
||||||
|
def document_page_delete(slug: str, page_id: int) -> Response | tuple[Response, int]:
|
||||||
|
"""Supprime une page, y compris la dernière restante — un support à 0
|
||||||
|
page est un état valide (retour utilisateur du 26/09/2026 : "l'éditeur
|
||||||
|
ne dois plus etre obliger d'avoir une page active", voir
|
||||||
|
document_engine/pages/pages.md). Voir aussi document_page_delete_all.py
|
||||||
|
pour tout supprimer d'un coup."""
|
||||||
|
if document_engine.get_document_page(slug, page_id) is None:
|
||||||
|
return jsonify({"error": "page introuvable"}), 404
|
||||||
|
document_engine.delete_document_page(slug, page_id)
|
||||||
|
return jsonify({"ok": True})
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
from flask import jsonify
|
||||||
|
from werkzeug.wrappers import Response
|
||||||
|
|
||||||
|
import document_engine
|
||||||
|
from core.flask_app import app
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/document/<slug>/pages/delete-all", methods=["POST"])
|
||||||
|
def document_page_delete_all(slug: str) -> Response:
|
||||||
|
"""Supprime toutes les pages du support d'un coup (retour utilisateur :
|
||||||
|
"une option dans page pour supprimer toute les page d'un coup") — le
|
||||||
|
support se retrouve à 0 page, exactement comme un support neuf. Action
|
||||||
|
destructive et irréversible, jamais appelée sans confirmation
|
||||||
|
explicite côté client (voir static/document/js/document-editor.js,
|
||||||
|
forgeDocDeleteAllPages)."""
|
||||||
|
document_engine.delete_all_document_pages(slug)
|
||||||
|
return jsonify({"ok": True})
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
from flask import jsonify, request
|
||||||
|
from werkzeug.wrappers import Response
|
||||||
|
|
||||||
|
import document_engine
|
||||||
|
from core.flask_app import app
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/document/<slug>/pages/<int:page_id>/move", methods=["POST"])
|
||||||
|
def document_page_move(slug: str, page_id: int) -> Response | tuple[Response, int]:
|
||||||
|
"""Réordonne une page dans la bande d'onglets — new_index calculé
|
||||||
|
côté client (glisser-déposer d'un onglet, voir static/document/js/
|
||||||
|
document-editor.js)."""
|
||||||
|
if document_engine.get_document_page(slug, page_id) is None:
|
||||||
|
return jsonify({"error": "page introuvable"}), 404
|
||||||
|
payload = request.get_json(force=True) or {}
|
||||||
|
new_index = int(payload.get("new_index", 0))
|
||||||
|
document_engine.move_document_page(slug, page_id, new_index)
|
||||||
|
return jsonify({"ok": True})
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
from flask import jsonify, request
|
||||||
|
from werkzeug.wrappers import Response
|
||||||
|
|
||||||
|
import document_engine
|
||||||
|
from core.flask_app import app
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/document/<slug>/pages/<int:page_id>/rename", methods=["POST"])
|
||||||
|
def document_page_rename(slug: str, page_id: int) -> Response | tuple[Response, int]:
|
||||||
|
"""Renomme une page — renvoie le titre RÉELLEMENT persisté (jamais
|
||||||
|
celui envoyé tel quel) : update_document_page ignore silencieusement
|
||||||
|
un titre vide, le client doit refléter ce que le serveur a vraiment
|
||||||
|
gardé, même convention que document_element_update.py."""
|
||||||
|
if document_engine.get_document_page(slug, page_id) is None:
|
||||||
|
return jsonify({"error": "page introuvable"}), 404
|
||||||
|
payload = request.get_json(force=True) or {}
|
||||||
|
document_engine.update_document_page(slug, page_id, str(payload.get("title", "")))
|
||||||
|
page = document_engine.get_document_page(slug, page_id)
|
||||||
|
title = page["title"] if page else ""
|
||||||
|
return jsonify({"ok": True, "title": title})
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
from flask import jsonify, request
|
||||||
|
from werkzeug.wrappers import Response
|
||||||
|
|
||||||
|
import document_engine
|
||||||
|
from core.flask_app import app
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/document/<slug>/pages/<int:page_id>/vertical-align", methods=["POST"])
|
||||||
|
def document_page_vertical_align(slug: str, page_id: int) -> Response | tuple[Response, int]:
|
||||||
|
"""Règle l'alignement vertical du contenu d'une page (voir panneau
|
||||||
|
Propriétés affiché quand l'onglet "Pages" est actif, static/document/
|
||||||
|
js/document-editor.js::forgeDocRenderPageProps) — renvoie la valeur
|
||||||
|
RÉELLEMENT persistée (jamais celle envoyée telle quelle) : une valeur
|
||||||
|
invalide retombe silencieusement sur "top", même convention que
|
||||||
|
document_page_rename.py pour un titre vide."""
|
||||||
|
if document_engine.get_document_page(slug, page_id) is None:
|
||||||
|
return jsonify({"error": "page introuvable"}), 404
|
||||||
|
payload = request.get_json(force=True) or {}
|
||||||
|
document_engine.set_document_page_vertical_align(slug, page_id, str(payload.get("vertical_align", "")))
|
||||||
|
page = document_engine.get_document_page(slug, page_id)
|
||||||
|
vertical_align = page["vertical_align"] if page else "top"
|
||||||
|
return jsonify({"ok": True, "vertical_align": vertical_align})
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
from flask import jsonify
|
from flask import jsonify, request
|
||||||
from werkzeug.wrappers import Response
|
from werkzeug.wrappers import Response
|
||||||
|
|
||||||
import document_engine
|
import document_engine
|
||||||
@@ -6,11 +6,33 @@ from core.flask_app import app
|
|||||||
|
|
||||||
|
|
||||||
@app.route("/document/<slug>/render")
|
@app.route("/document/<slug>/render")
|
||||||
def document_render(slug: str) -> Response:
|
def document_render(slug: str) -> Response | tuple[Response, int]:
|
||||||
"""Rendu HTML complet et à jour du document — appelé par le moteur de
|
"""Rendu HTML complet et à jour d'UNE PAGE du document (?page_id=) —
|
||||||
layout côté client après toute mutation structurelle (ajout/déplacement/
|
appelé par le moteur de layout côté client après toute mutation
|
||||||
suppression) pour reconstruire le canevas depuis la vérité serveur
|
structurelle (ajout/déplacement/suppression) pour reconstruire le
|
||||||
(recalcul réel des rangées, jamais une simple retouche DOM locale, voir
|
canevas depuis la vérité serveur (recalcul réel des rangées, jamais
|
||||||
docs/plan/PLAN.md — "recalcul au déplacement")."""
|
une simple retouche DOM locale, voir docs/plan/PLAN.md — "recalcul au
|
||||||
elements = document_engine.list_document_elements(slug)
|
déplacement"). Le canevas n'affiche jamais qu'une seule page à la
|
||||||
return jsonify({"html": document_engine.render_document(elements), "elements": elements})
|
fois (voir document_engine/pages/), d'où ce paramètre.
|
||||||
|
|
||||||
|
`elements` est revalidé (document_engine.sanitize_element_attributes)
|
||||||
|
avant d'être renvoyé au client — même raison que routes/document/
|
||||||
|
document_edit.py : le panneau Propriétés reçoit `data.elements`
|
||||||
|
directement depuis cette route à chaque rafraîchissement du canevas."""
|
||||||
|
page_id = request.args.get("page_id", type=int)
|
||||||
|
if page_id is None:
|
||||||
|
return jsonify({"error": "page introuvable"}), 404
|
||||||
|
page = document_engine.get_document_page(slug, page_id)
|
||||||
|
if page is None:
|
||||||
|
return jsonify({"error": "page introuvable"}), 404
|
||||||
|
elements = [
|
||||||
|
{**el, "attributes": document_engine.sanitize_element_attributes(el["kind"], el["attributes"])}
|
||||||
|
for el in document_engine.list_document_elements(slug, page_id)
|
||||||
|
]
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"html": document_engine.render_document(elements),
|
||||||
|
"elements": elements,
|
||||||
|
"vertical_align": page["vertical_align"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
from flask import jsonify, request
|
||||||
|
from werkzeug.wrappers import Response
|
||||||
|
|
||||||
|
import db
|
||||||
|
import document_engine
|
||||||
|
from core.flask_app import app
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/document/<slug>/theme/apply", methods=["POST"])
|
||||||
|
def document_theme_apply(slug: str) -> Response | tuple[Response, int]:
|
||||||
|
"""Applique un thème visuel au support, ou le RETIRE — voir
|
||||||
|
document_engine/themes/. `mode` décide du sort du contenu ACTUEL :
|
||||||
|
- "keep_content" (défaut) : seul le thème change, le contenu du
|
||||||
|
support reste tel quel.
|
||||||
|
- "template_content" : le contenu du support est intégralement
|
||||||
|
remplacé par le contenu de démonstration du thème (voir
|
||||||
|
document_engine.replace_document_content) — action destructive,
|
||||||
|
dont la confirmation est à la charge du client (voir
|
||||||
|
static/document/js/document-editor.js, aucune confirmation ici
|
||||||
|
côté serveur : ce n'est pas son rôle).
|
||||||
|
|
||||||
|
`theme_id` vide (retour utilisateur du 26/09/2026 : la modale
|
||||||
|
propose une carte "Aucun modèle" pour "revenir à un document de
|
||||||
|
base") retire le thème (`db.remove_document_theme`) et s'arrête là
|
||||||
|
— jamais de contenu de démonstration à appliquer pour "aucun
|
||||||
|
modèle", `mode` n'a alors aucun sens et est ignoré."""
|
||||||
|
payload = request.get_json(force=True) or {}
|
||||||
|
theme_id = str(payload.get("theme_id", ""))
|
||||||
|
if not theme_id:
|
||||||
|
db.remove_document_theme(slug)
|
||||||
|
return jsonify({"ok": True, "theme_id": ""})
|
||||||
|
|
||||||
|
theme = document_engine.get_document_theme_entry(theme_id)
|
||||||
|
if theme is None:
|
||||||
|
return jsonify({"error": "thème introuvable"}), 404
|
||||||
|
mode = payload.get("mode", "keep_content")
|
||||||
|
if mode not in ("keep_content", "template_content"):
|
||||||
|
return jsonify({"error": "mode invalide"}), 400
|
||||||
|
|
||||||
|
db.set_document_theme(slug, theme_id)
|
||||||
|
if mode == "template_content":
|
||||||
|
document_engine.replace_document_content(slug, theme["seed_pages"])
|
||||||
|
|
||||||
|
return jsonify({"ok": True, "theme_id": theme_id})
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
from flask import render_template
|
||||||
|
from werkzeug.exceptions import NotFound
|
||||||
|
|
||||||
|
import document_engine
|
||||||
|
from core.flask_app import app
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/document/<slug>/theme/<theme_id>/preview")
|
||||||
|
def document_theme_preview(slug: str, theme_id: str) -> str:
|
||||||
|
"""Aperçu isolé (destiné à un <iframe>, voir la modale "Utiliser un
|
||||||
|
modèle" dans static/document/js/document-editor.js) de TOUTES les
|
||||||
|
pages de démonstration d'un thème — RÉELLEMENT rendues par le moteur
|
||||||
|
(document_engine.render_document), jamais une image statique ou une
|
||||||
|
resucée manuelle du CSS : ce qui est prévisualisé est EXACTEMENT ce
|
||||||
|
qui s'affichera une fois le thème appliqué. La navigation entre pages
|
||||||
|
(retour utilisateur du 24/09/2026 : "je dois pouvoir naviguer dans
|
||||||
|
l'aperçu pour voir toutes les pages") se fait entièrement côté
|
||||||
|
client dans le template, jamais par un nouvel aller-retour serveur —
|
||||||
|
toutes les pages sont déjà rendues ici en une fois. `slug` sert
|
||||||
|
uniquement à la garde de propriété (core/auth_guard.py, générique
|
||||||
|
sur toute route <slug>) — l'aperçu ne dépend d'aucune donnée de CE
|
||||||
|
support."""
|
||||||
|
theme = document_engine.get_document_theme_entry(theme_id)
|
||||||
|
if theme is None:
|
||||||
|
raise NotFound
|
||||||
|
pages = [
|
||||||
|
{
|
||||||
|
"vertical_align": seed_page.get("vertical_align", "top"),
|
||||||
|
"html": document_engine.render_document(document_engine.seed_blocks_to_elements(seed_page["blocks"])),
|
||||||
|
}
|
||||||
|
for seed_page in theme["seed_pages"]
|
||||||
|
]
|
||||||
|
return render_template("document/document_theme_preview.html", theme=theme, pages=pages)
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
from flask import send_from_directory
|
||||||
|
from werkzeug.wrappers import Response
|
||||||
|
|
||||||
|
import db
|
||||||
|
from core.flask_app import app
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/document/<slug>/uploads/<path:filename>")
|
||||||
|
def document_uploaded_file(slug: str, filename: str) -> Response:
|
||||||
|
"""Sert un fichier téléversé pour ce support (voir
|
||||||
|
document_element_upload_image.py) — mirroir de
|
||||||
|
routes/uploads/uploaded_file.py côté jeu, mais sous le dossier du
|
||||||
|
SUPPORT (db.support_dir). Affiché inline (jamais en téléchargement,
|
||||||
|
contrairement à document_element_download_attachment.py) : c'est une
|
||||||
|
image destinée à s'afficher dans la page, pas un fichier à
|
||||||
|
récupérer."""
|
||||||
|
return send_from_directory(os.path.join(db.support_dir(slug), "uploads"), filename)
|
||||||
+1720
-86
File diff suppressed because it is too large
Load Diff
+3138
-115
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,282 @@
|
|||||||
|
/* Thème "Sécurité Incendie" (document_engine/themes/theme_catalog.py) —
|
||||||
|
habille le contenu ET peut ajouter des flourishes décoratifs à
|
||||||
|
.docPage (ex. le liseré ::before ci-dessous), mais ne touche JAMAIS à
|
||||||
|
son FORMAT : width/aspect-ratio/max-height/overflow/background
|
||||||
|
restent la mécanique du moteur (voir static/document/
|
||||||
|
document-editor.css, retours utilisateur des 23-24/09/2026 sur le
|
||||||
|
format A4 paysage et la page toujours blanche) — un thème ne les
|
||||||
|
redéfinit jamais, sous peine de casser le format garanti de la page.
|
||||||
|
Chargé APRÈS document-editor.css (voir templates/document/
|
||||||
|
document_edit.html et document_theme_preview.html), donc prioritaire
|
||||||
|
à spécificité égale sur les quelques couleurs de contenu que le
|
||||||
|
moteur pose par défaut (--forge-text, --doc-text). */
|
||||||
|
|
||||||
|
.docPageContent {
|
||||||
|
--theme-ink: #241f1c;
|
||||||
|
--theme-ink-muted: #6b6259;
|
||||||
|
--theme-line: #e7dfd2;
|
||||||
|
--theme-safety-red: #c8102e;
|
||||||
|
--theme-safety-red-dark: #8f0b20;
|
||||||
|
--theme-safety-red-tint: #fbe9ec;
|
||||||
|
--theme-hazard-amber: #e3a72e;
|
||||||
|
--theme-hazard-amber-dark: #a97815;
|
||||||
|
--theme-ok-green: #2e7d46;
|
||||||
|
--theme-font-display: "Oswald", sans-serif;
|
||||||
|
--theme-font-body: "Source Sans 3", sans-serif;
|
||||||
|
|
||||||
|
font-family: var(--theme-font-body);
|
||||||
|
|
||||||
|
--forge-text: var(--theme-ink);
|
||||||
|
--forge-text-muted: var(--theme-ink-muted);
|
||||||
|
|
||||||
|
/* --doc-accent/--doc-accent-2 pilotent la barre de progression et le
|
||||||
|
survol des options du Quiz (voir static/document/
|
||||||
|
document-editor.css, .docQuizProgressFill/.docQuizOption:hover) —
|
||||||
|
jamais redéfinis par .docPage lui-même (seul --doc-accent-* du
|
||||||
|
CHROME de l'éditeur s'appliquerait sinon, couleur orange générique
|
||||||
|
sans rapport avec ce thème). */
|
||||||
|
--doc-accent: var(--theme-safety-red);
|
||||||
|
--doc-accent-2: var(--theme-hazard-amber);
|
||||||
|
}
|
||||||
|
|
||||||
|
.docText[data-kind="titre"] {
|
||||||
|
font-family: var(--theme-font-display);
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docText[data-kind="paragraphe"] {
|
||||||
|
font-family: var(--theme-font-body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Liseré décoratif en haut de page — pas une propriété de format
|
||||||
|
(taille/position de .docPage inchangées, déjà position:relative et
|
||||||
|
overflow:hidden côté moteur), juste une bande de couleur superposée. ---- */
|
||||||
|
.docPage::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 5px;
|
||||||
|
background: linear-gradient(90deg, var(--theme-safety-red), var(--theme-hazard-amber));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Étiquette (badge) : encart compact type avertissement/repère.
|
||||||
|
L'icône est maintenant un vrai contenu (attribut svg_markup, voir
|
||||||
|
_render_badge) plutôt qu'un glyphe CSS décoratif — un thème n'a plus
|
||||||
|
qu'à dimensionner l'icône fournie, jamais à en inventer une. Largeur/
|
||||||
|
arrondi/gras/majuscules restent des attributs PAR ÉLÉMENT (voir
|
||||||
|
element_kind_labels.element_default_attributes) : ce thème ne pose
|
||||||
|
ici que l'apparence par défaut d'un badge SANS ces réglages (pleine
|
||||||
|
largeur, 7px d'arrondi) — le contenu-seed du kicker "Module
|
||||||
|
obligatoire" les override lui-même (voir securite_incendie_seed.py). ---- */
|
||||||
|
.docBadge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-family: var(--theme-font-body);
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: var(--theme-safety-red-dark);
|
||||||
|
background: var(--theme-safety-red-tint);
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docBadgeIcon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--theme-safety-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.docBadgeIcon svg {
|
||||||
|
width: 13px;
|
||||||
|
height: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Liste à puces / numérotée : coche sécurité / pastille panneau ----
|
||||||
|
list-style:none supprime la puce/le numéro NATIF : ce thème affiche
|
||||||
|
son propre badge (::before ci-dessous) à la place, jamais les deux à
|
||||||
|
la fois (bug réel constaté le 26/09/2026 — sans ce reset, la feuille
|
||||||
|
de base affiche désormais aussi le marqueur natif en plus du badge,
|
||||||
|
voir static/document/document-editor.css, .docList li::before, qui a
|
||||||
|
dû arrêter de neutraliser le display natif du <li> pour corriger un
|
||||||
|
autre bug — retirer le thème faisait disparaître tout marqueur). ---- */
|
||||||
|
.docList {
|
||||||
|
font-family: var(--theme-font-body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sélecteur avec le type d'élément (ul.../ol...), pas seulement les
|
||||||
|
classes/attributs : static/style.css (site-wide) porte une règle
|
||||||
|
`.content ol:not([type]) { list-style-type: decimal; }` d'une
|
||||||
|
spécificité légèrement supérieure (elle inclut le sélecteur d'élément
|
||||||
|
`ol`) qui l'emportait sinon silencieusement sur ce reset, même si
|
||||||
|
celui-ci charge après. */
|
||||||
|
ul.docList[data-kind="liste_puces"],
|
||||||
|
ol.docList[data-kind="liste_numerotee"] {
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docList li {
|
||||||
|
background: #faf7f2;
|
||||||
|
border: 1px solid var(--theme-line);
|
||||||
|
border-left: 3px solid var(--theme-hazard-amber);
|
||||||
|
border-radius: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docList[data-kind="liste_puces"] li::before {
|
||||||
|
content: "";
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
margin-top: 1px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--theme-ok-green);
|
||||||
|
mask: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M9 16.2l-3.5-3.5L4 14.2l5 5 11-11-1.5-1.5z"/></svg>') center / 11px no-repeat;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docList[data-kind="liste_numerotee"] {
|
||||||
|
counter-reset: doc-theme-step;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docList[data-kind="liste_numerotee"] li::before {
|
||||||
|
counter-increment: doc-theme-step;
|
||||||
|
content: counter(doc-theme-step);
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
|
||||||
|
/* inline-flex, jamais flex : ce badge doit rester un ::before qui
|
||||||
|
s'écoule EN LIGNE à côté du texte de l'élément (voir
|
||||||
|
static/document/document-editor.css, .docList li::before) — flex
|
||||||
|
(sans inline-) le blockifie, ce qui le repousse au-dessus du texte
|
||||||
|
plutôt qu'à côté (bug réel constaté le 26/09/2026). inline-flex
|
||||||
|
garde ce comportement en ligne tout en centrant quand même le
|
||||||
|
chiffre à l'intérieur du badge (align-items/justify-content). */
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--theme-safety-red);
|
||||||
|
color: #fff;
|
||||||
|
font-family: var(--theme-font-display);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docList[data-kind="liste_numerotee"] li {
|
||||||
|
border-left-color: var(--theme-safety-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Carte : classes de feu (repère en pastille + titre + description).
|
||||||
|
Comme .docImage ci-dessus, .docCard n'a AUCUNE mise en page par défaut
|
||||||
|
côté moteur (voir _render_carte) : ce thème lui donne sa forme de
|
||||||
|
carte centrée, et une largeur égale entre cartes voisines dans une
|
||||||
|
rangée. ---- */
|
||||||
|
.docCard {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
text-align: center;
|
||||||
|
padding: 12px 8px;
|
||||||
|
background: #faf7f2;
|
||||||
|
border: 1px solid var(--theme-line);
|
||||||
|
border-radius: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docRow > .docCard {
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docCardLabel {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
font-family: var(--theme-font-display);
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
background: var(--theme-ink-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.docCardTitle {
|
||||||
|
font-family: var(--theme-font-body);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--theme-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.docCardDescription {
|
||||||
|
font-family: var(--theme-font-body);
|
||||||
|
font-size: 10.5px;
|
||||||
|
color: var(--theme-ink-muted);
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Bouton d'action ---- */
|
||||||
|
.docButton {
|
||||||
|
font-family: var(--theme-font-display);
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
background: var(--theme-safety-red);
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 7px;
|
||||||
|
box-shadow: 0 6px 16px -6px rgb(200 16 46 / 55%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Image : cadre pour un pictogramme SVG dessiné (voir
|
||||||
|
document_engine/rendering/render_document_element.py::_render_image,
|
||||||
|
mode svg_markup). Ce <div> n'a AUCUNE taille par défaut côté moteur
|
||||||
|
(seuls .docImagePlaceholder et <img class="docImage"> en ont une) :
|
||||||
|
ce thème lui donne une hauteur/un centrage propres, sinon le SVG
|
||||||
|
s'affiche à sa taille intrinsèque dans une boîte sans forme. ---- */
|
||||||
|
.docImage {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 140px;
|
||||||
|
padding: 16px;
|
||||||
|
background: #fdf3de;
|
||||||
|
border: 1px solid var(--theme-line);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docImage svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
max-width: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dans une rangée (voir .docRow), l'image reste une colonne compacte
|
||||||
|
plutôt que de se partager équitablement l'espace avec le texte à côté
|
||||||
|
— c'est un pictogramme d'appoint, pas le contenu principal de la
|
||||||
|
rangée. */
|
||||||
|
.docRow > .docImage {
|
||||||
|
flex: 0 1 200px;
|
||||||
|
align-self: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Quiz : palette rouge sécurité sur fond blanc ---- */
|
||||||
|
.docQuizKicker {
|
||||||
|
font-family: var(--theme-font-display);
|
||||||
|
color: var(--theme-safety-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.docQuizPlayerTitle {
|
||||||
|
font-family: var(--theme-font-display);
|
||||||
|
}
|
||||||
|
|
||||||
|
.docQuizOption {
|
||||||
|
background: #faf7f2;
|
||||||
|
border-color: var(--theme-line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.docQuizOptionLetter {
|
||||||
|
font-family: var(--theme-font-display);
|
||||||
|
}
|
||||||
@@ -5,6 +5,13 @@
|
|||||||
{% block extra_head %}
|
{% block extra_head %}
|
||||||
<link rel="stylesheet"
|
<link rel="stylesheet"
|
||||||
href="{{ url_for('static', filename='document/document-editor.css') }}">
|
href="{{ url_for('static', filename='document/document-editor.css') }}">
|
||||||
|
{% if active_theme %}
|
||||||
|
{% if active_theme.font_url %}
|
||||||
|
<link rel="stylesheet" href="{{ active_theme.font_url }}">
|
||||||
|
{% endif %}
|
||||||
|
<link rel="stylesheet"
|
||||||
|
href="{{ url_for('static', filename=active_theme.css_path) }}">
|
||||||
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="docEditor3" id="docEditor3" data-slug="{{ support.slug }}">
|
<div class="docEditor3" id="docEditor3" data-slug="{{ support.slug }}">
|
||||||
@@ -76,6 +83,23 @@
|
|||||||
<circle cx="12" cy="12" r="3" />
|
<circle cx="12" cy="12" r="3" />
|
||||||
</svg><span class="docBtnLabel">Aperçu</span>
|
</svg><span class="docBtnLabel">Aperçu</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button"
|
||||||
|
class="docBtnSecondary"
|
||||||
|
id="docTemplateBtn">
|
||||||
|
<svg width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.8"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round">
|
||||||
|
<rect x="3" y="3" width="7" height="7" rx="1" />
|
||||||
|
<rect x="14" y="3" width="7" height="7" rx="1" />
|
||||||
|
<rect x="3" y="14" width="7" height="7" rx="1" />
|
||||||
|
<rect x="14" y="14" width="7" height="7" rx="1" />
|
||||||
|
</svg><span class="docBtnLabel">Utiliser un modèle</span>
|
||||||
|
</button>
|
||||||
<div class="docDividerV"></div>
|
<div class="docDividerV"></div>
|
||||||
<div class="docWidthPicker"
|
<div class="docWidthPicker"
|
||||||
id="docWidthPicker"
|
id="docWidthPicker"
|
||||||
@@ -95,21 +119,79 @@
|
|||||||
<div class="docBodyWrap">
|
<div class="docBodyWrap">
|
||||||
<div class="docSidebarClose" id="docCloseLeft">← Retour au document</div>
|
<div class="docSidebarClose" id="docCloseLeft">← Retour au document</div>
|
||||||
<div class="docSidebar docSidebarLeft" id="docSidebarLeft">
|
<div class="docSidebar docSidebarLeft" id="docSidebarLeft">
|
||||||
{% for category_key, category in element_library.items() %}
|
{# Panneau gauche à onglets (retour utilisateur du 23/09/2026 :
|
||||||
<div>
|
"il dois avoir deux onglets") : "Mise en page" (bibliothèque
|
||||||
<div class="docSectionLabel">{{ category.label }}</div>
|
d'éléments, contenu inchangé) actif par défaut — le panneau
|
||||||
{% if category_key == "mise_en_page" %}
|
"Pages" (gestion complète des pages : liste/réordonner/ajouter/
|
||||||
<div class="docShapesGrid">
|
supprimer/renommer) est un espace dédié plein-écran plutôt que
|
||||||
{% for kind in category.kinds %}
|
de se partager la hauteur avec la bibliothèque comme les
|
||||||
<div class="docLibItem docLibItem--shape"
|
itérations précédentes (bande de vignettes, puis carrousel). #}
|
||||||
draggable="true"
|
<div class="docSidebarTabs" role="tablist">
|
||||||
data-add="{{ kind }}">
|
<button type="button"
|
||||||
<span class="docLibIcon docLibIcon--{{ kind }}"></span>
|
class="docSidebarTab"
|
||||||
<span>{{ element_kind_labels[kind] }}</span>
|
id="docTabPages"
|
||||||
</div>
|
role="tab"
|
||||||
{% endfor %}
|
aria-selected="false"
|
||||||
|
aria-controls="docTabPanelPages"
|
||||||
|
data-tab="pages">Pages</button>
|
||||||
|
<button type="button"
|
||||||
|
class="docSidebarTab is-active"
|
||||||
|
id="docTabLayout"
|
||||||
|
role="tab"
|
||||||
|
aria-selected="true"
|
||||||
|
aria-controls="docTabPanelLayout"
|
||||||
|
data-tab="layout">Mise en page</button>
|
||||||
|
</div>
|
||||||
|
<div class="docSidebarTabPanel"
|
||||||
|
id="docTabPanelPages"
|
||||||
|
role="tabpanel"
|
||||||
|
aria-labelledby="docTabPages"
|
||||||
|
hidden>
|
||||||
|
{# Bouton d'ajout EN HAUT, toujours visible (retour utilisateur du
|
||||||
|
23/09/2026 : "le bouton pour ajouter une page dois être en haut
|
||||||
|
toujours visible" — avec beaucoup de pages, il disparaissait en
|
||||||
|
bas de la liste défilante). #}
|
||||||
|
<button type="button" class="docBtnSecondary docPageManagerAdd" id="docPageManagerAdd">+ Ajouter une page</button>
|
||||||
|
<button type="button"
|
||||||
|
class="docDeleteBtn docPageManagerDeleteAll"
|
||||||
|
id="docPageManagerDeleteAll"
|
||||||
|
{{ 'disabled' if not pages else '' }}>🗑 Supprimer toutes les pages</button>
|
||||||
|
<div class="docPageManagerList" id="docPageManagerList">
|
||||||
|
{% for page in pages %}
|
||||||
|
<div class="docPageRow {{ 'is-active' if page.id == active_page.id else '' }}"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
draggable="true"
|
||||||
|
data-page-id="{{ page.id }}">
|
||||||
|
{# //NOSONAR Web:S6819,Web:MouseEventWithoutKeyboardEquivalentCheck - div+role=button
|
||||||
|
volontaire : la rangée contient de vrais <button> d'action (renommer/supprimer),
|
||||||
|
qu'un <button> englobant ne pourrait pas contenir validement. L'équivalent clavier
|
||||||
|
(Entrée/Espace) est posé côté JS (forgeDocRenderPageManagerList), donc le finding
|
||||||
|
clavier de Sonar est un faux positif : l'analyseur statique ne voit pas les
|
||||||
|
addEventListener dynamiques. #}
|
||||||
|
<span class="docPageRowHandle" aria-hidden="true">⠿</span>
|
||||||
|
<span class="docPageRowNumber">{{ loop.index }}</span>
|
||||||
|
<span class="docPageRowTitle"><span class="docPageRowTitleText">{{ page.title }}</span></span>
|
||||||
|
<span class="docPageRowActions">
|
||||||
|
<button type="button"
|
||||||
|
class="docPageRowRename"
|
||||||
|
data-page-id="{{ page.id }}"
|
||||||
|
aria-label="Renommer cette page"
|
||||||
|
title="Renommer cette page">✎</button>
|
||||||
|
<button type="button"
|
||||||
|
class="docPageRowDelete"
|
||||||
|
data-page-id="{{ page.id }}"
|
||||||
|
aria-label="Supprimer cette page"
|
||||||
|
title="Supprimer cette page">✕</button>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="docSidebarTabPanel" id="docTabPanelLayout" role="tabpanel" aria-labelledby="docTabLayout">
|
||||||
|
{% for category_key, category in element_library.items() %}
|
||||||
|
<div>
|
||||||
|
<div class="docSectionLabel">{{ category.label }}</div>
|
||||||
<div class="docLibList">
|
<div class="docLibList">
|
||||||
{% for kind in category.kinds %}
|
{% for kind in category.kinds %}
|
||||||
<div class="docLibItem docLibItem--row"
|
<div class="docLibItem docLibItem--row"
|
||||||
@@ -120,21 +202,32 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
</div>
|
||||||
</div>
|
{% endfor %}
|
||||||
{% endfor %}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="docCanvasArea" id="docCanvasArea">
|
<div class="docCanvasArea" id="docCanvasArea">
|
||||||
<div class="docPage" id="docPage">
|
<div class="docPage" id="docPage">
|
||||||
<div class="docPageContent" id="docPageContent">{{ rendered_document|safe }}</div>
|
<div class="docPageContent"
|
||||||
{# //NOSONAR S5247 - rendered_document vient de document_engine.render_document, qui échappe (html.escape) tout contenu utilisateur avant interpolation (voir document_engine/rendering/render_document_element.py) ; jamais de HTML brut non échappé ici #}
|
id="docPageContent"
|
||||||
<div class="docSnapGridOverlay" aria-hidden="true"></div>
|
data-vertical-align="{{ active_page.vertical_align if active_page else 'top' }}">{% if active_page %}{{ rendered_document|safe }}{% else %}<div class="docEmptyState docEmptyPageState">Aucune page — cliquez sur « + Ajouter une page » pour commencer.</div>{% endif %}</div>
|
||||||
|
{# //NOSONAR S5247 - rendered_document vient de document_engine.render_document, qui échappe (html.escape) tout contenu utilisateur avant interpolation (voir document_engine/rendering/render_document_element.py) ; jamais de HTML brut non échappé ici. Le message "Aucune page" est un littéral, jamais un contenu utilisateur. #}
|
||||||
</div>
|
</div>
|
||||||
|
<button type="button"
|
||||||
|
class="docPreviewExitBtn"
|
||||||
|
id="docPreviewExitBtn"
|
||||||
|
aria-label="Retour à l'éditeur"
|
||||||
|
title="Retour à l'éditeur">✕ Retour à l'éditeur</button>
|
||||||
<div class="docZoomPill">
|
<div class="docZoomPill">
|
||||||
<button type="button" id="docZoomOut" aria-label="Zoom -">−</button>
|
<button type="button" id="docZoomOut" aria-label="Zoom -">−</button>
|
||||||
<span id="docZoomVal">100%</span>
|
<span id="docZoomVal">100%</span>
|
||||||
<button type="button" id="docZoomIn" aria-label="Zoom +">+</button>
|
<button type="button" id="docZoomIn" aria-label="Zoom +">+</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="docPageNavPill" id="docPageNavPill">
|
||||||
|
<button type="button" id="docPagePrevBtn" aria-label="Page précédente">‹</button>
|
||||||
|
<span id="docPageNavLabel"></span>
|
||||||
|
<button type="button" id="docPageNextBtn" aria-label="Page suivante">›</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="docSidebarClose" id="docCloseRight">← Retour au document</div>
|
<div class="docSidebarClose" id="docCloseRight">← Retour au document</div>
|
||||||
<div class="docSidebar docSidebarRight" id="docSidebarRight">
|
<div class="docSidebar docSidebarRight" id="docSidebarRight">
|
||||||
@@ -150,11 +243,55 @@
|
|||||||
<button type="button" data-target="right">Propriétés</button>
|
<button type="button" data-target="right">Propriétés</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="docModalBackdrop" id="docScenarioTreeModal">
|
||||||
|
<div class="docModalDialog">
|
||||||
|
<div class="docModalHeader">
|
||||||
|
<span class="docModalTitle" id="docScenarioTreeModalTitle">Arbre du scénario</span>
|
||||||
|
<button type="button"
|
||||||
|
class="docModalCloseBtn"
|
||||||
|
id="docScenarioTreeModalClose"
|
||||||
|
aria-label="Fermer">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="docModalBody" id="docScenarioTreeModalBody"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="docModalBackdrop" id="docTemplateModal">
|
||||||
|
<div class="docModalDialog docModalDialog--wide">
|
||||||
|
<div class="docModalHeader">
|
||||||
|
<span class="docModalTitle">Utiliser un modèle</span>
|
||||||
|
<button type="button"
|
||||||
|
class="docModalCloseBtn"
|
||||||
|
id="docTemplateModalClose"
|
||||||
|
aria-label="Fermer">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="docModalBody docTemplateModalBody">
|
||||||
|
<div class="docTemplateList" id="docTemplateList"></div>
|
||||||
|
<div class="docTemplatePreviewPane">
|
||||||
|
<iframe class="docTemplatePreviewFrame"
|
||||||
|
id="docTemplatePreviewFrame"
|
||||||
|
title="Aperçu du modèle"></iframe>
|
||||||
|
<div class="docTemplatePreviewEmpty"
|
||||||
|
id="docTemplatePreviewEmpty">Sélectionnez un modèle pour le visualiser.</div>
|
||||||
|
<div class="docTemplatePreviewActions"
|
||||||
|
id="docTemplatePreviewActions"
|
||||||
|
hidden>
|
||||||
|
<button type="button" class="docBtnSecondary" id="docTemplateKeepContentBtn">Utiliser mon contenu actuel</button>
|
||||||
|
<button type="button" class="docBtnSecondary docBtnPrimary" id="docTemplateUseContentBtn">Utiliser le contenu du modèle</button>
|
||||||
|
<button type="button" class="docBtnSecondary docBtnPrimary" id="docTemplateRemoveBtn" hidden>Retirer le modèle</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<script>
|
<script>
|
||||||
window.FORGE_DOCUMENT = {
|
window.FORGE_DOCUMENT = {
|
||||||
slug: {{ support.slug|tojson }},
|
slug: {{ support.slug|tojson }},
|
||||||
elements: {{ elements|tojson }},
|
elements: {{ elements|tojson }},
|
||||||
elementKindLabels: {{ element_kind_labels|tojson }}
|
elementKindLabels: {{ element_kind_labels|tojson }},
|
||||||
|
pages: {{ pages|tojson }},
|
||||||
|
activePageId: {{ (active_page.id if active_page else none)|tojson }},
|
||||||
|
themes: {{ document_themes|tojson }},
|
||||||
|
activeThemeId: {{ support.theme|tojson }}
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
<script src="{{ url_for('static', filename='document/js/document-editor.js') }}"></script>
|
<script src="{{ url_for('static', filename='document/js/document-editor.js') }}"></script>
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Aperçu — {{ theme.name }}</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='document/document-editor.css') }}">
|
||||||
|
{% if theme.font_url %}
|
||||||
|
<link rel="stylesheet" href="{{ theme.font_url }}">
|
||||||
|
{% endif %}
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename=theme.css_path) }}">
|
||||||
|
<style>
|
||||||
|
html, body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
height: 100%;
|
||||||
|
background: #efece4;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.previewPages {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* La page réelle a une largeur/un ratio FIXES côté moteur
|
||||||
|
(--doc-page-width/aspect-ratio, voir static/document/
|
||||||
|
document-editor.css) — sans mise à l'échelle, elle déborde
|
||||||
|
verticalement de cette fenêtre d'aperçu plus petite qu'un canevas
|
||||||
|
d'édition en plein écran, et défile/rogne au lieu de tenir
|
||||||
|
entière (retour utilisateur du 24/09/2026). `forgeDocFitPreviewPage`
|
||||||
|
calcule un facteur d'échelle qui la fait TOUJOURS tenir
|
||||||
|
entièrement, sans barre de défilement. */
|
||||||
|
.previewPages .docPage {
|
||||||
|
flex-shrink: 0;
|
||||||
|
transform-origin: center center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.previewPages .docPage[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.previewNav {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 10px;
|
||||||
|
background: #1a1b20;
|
||||||
|
color: #fff;
|
||||||
|
font: 600 13px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.previewNav button {
|
||||||
|
border: none;
|
||||||
|
background: #2a2c33;
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.previewNav button:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="previewPages">
|
||||||
|
{% for page in pages %}
|
||||||
|
<div class="docPage" data-page-index="{{ loop.index0 }}" {% if not loop.first %}hidden{% endif %}>
|
||||||
|
<div class="docPageContent" data-vertical-align="{{ page.vertical_align }}">
|
||||||
|
{{ page.html | safe }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% if pages|length > 1 %}
|
||||||
|
<div class="previewNav">
|
||||||
|
<button type="button" id="previewPrevBtn">‹ Précédent</button>
|
||||||
|
<span id="previewPageLabel"></span>
|
||||||
|
<button type="button" id="previewNextBtn">Suivant ›</button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<script>
|
||||||
|
(function previewPageNav() {
|
||||||
|
var pages = Array.prototype.slice.call(document.querySelectorAll('.previewPages .docPage'));
|
||||||
|
if (pages.length <= 1) return;
|
||||||
|
var current = 0;
|
||||||
|
var prevBtn = document.getElementById('previewPrevBtn');
|
||||||
|
var nextBtn = document.getElementById('previewNextBtn');
|
||||||
|
var label = document.getElementById('previewPageLabel');
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
pages.forEach(function pageAt(page, index) {
|
||||||
|
page.hidden = index !== current;
|
||||||
|
});
|
||||||
|
label.textContent = 'Page ' + (current + 1) + ' / ' + pages.length;
|
||||||
|
prevBtn.disabled = current === 0;
|
||||||
|
nextBtn.disabled = current === pages.length - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
prevBtn.addEventListener('click', function goPrev() {
|
||||||
|
if (current > 0) { current -= 1; render(); }
|
||||||
|
});
|
||||||
|
nextBtn.addEventListener('click', function goNext() {
|
||||||
|
if (current < pages.length - 1) { current += 1; render(); }
|
||||||
|
});
|
||||||
|
render();
|
||||||
|
}());
|
||||||
|
|
||||||
|
(function fitPreviewPage() {
|
||||||
|
// Toutes les pages partagent EXACTEMENT la même largeur/le même ratio
|
||||||
|
// fixes côté moteur (--doc-page-width/aspect-ratio) quel que soit leur
|
||||||
|
// contenu (voir static/document/document-editor.css) : mesurer une
|
||||||
|
// seule page suffit à connaître la taille "naturelle" de toutes.
|
||||||
|
var container = document.querySelector('.previewPages');
|
||||||
|
var pages = Array.prototype.slice.call(document.querySelectorAll('.previewPages .docPage'));
|
||||||
|
if (!container || !pages.length) return;
|
||||||
|
var reference = pages[0];
|
||||||
|
|
||||||
|
function applyFit() {
|
||||||
|
var wasHidden = reference.hidden;
|
||||||
|
var previousTransform = reference.style.transform;
|
||||||
|
reference.hidden = false;
|
||||||
|
reference.style.transform = 'none';
|
||||||
|
var naturalWidth = reference.offsetWidth;
|
||||||
|
var naturalHeight = reference.offsetHeight;
|
||||||
|
reference.hidden = wasHidden;
|
||||||
|
reference.style.transform = previousTransform;
|
||||||
|
if (!naturalWidth || !naturalHeight) return;
|
||||||
|
var availableWidth = container.clientWidth - 32;
|
||||||
|
var availableHeight = container.clientHeight - 32;
|
||||||
|
// Jamais agrandie au-delà de sa taille réelle (max 1) — un simple
|
||||||
|
// rétrécissement pour tenir, jamais un zoom artificiel.
|
||||||
|
var scale = Math.min(availableWidth / naturalWidth, availableHeight / naturalHeight, 1);
|
||||||
|
pages.forEach(function scalePage(page) {
|
||||||
|
page.style.transform = 'scale(' + scale + ')';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
applyFit();
|
||||||
|
window.addEventListener('resize', applyFit);
|
||||||
|
}());
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+11
-1
@@ -205,12 +205,22 @@ def support(client: FlaskClient) -> Generator[str, None, None]:
|
|||||||
"""Crée un support de formation de test frais via /documents/new
|
"""Crée un support de formation de test frais via /documents/new
|
||||||
(mirroir de la fixture `game` ci-dessus, pour l'autre type de projet —
|
(mirroir de la fixture `game` ci-dessus, pour l'autre type de projet —
|
||||||
entité racine séparée, voir docs/plan/PLAN.md) et le supprime après le
|
entité racine séparée, voir docs/plan/PLAN.md) et le supprime après le
|
||||||
test, quel que soit son résultat."""
|
test, quel que soit son résultat.
|
||||||
|
|
||||||
|
Un support neuf n'a plus aucune page par défaut (retour utilisateur
|
||||||
|
du 26/09/2026, voir document_engine/pages/pages.md) — cette fixture
|
||||||
|
lui en crée une par convénience, puisque la quasi-totalité des tests
|
||||||
|
existants portent sur du contenu et présupposent une première page
|
||||||
|
"Page 1" déjà là (comme avant ce changement). Les tests qui portent
|
||||||
|
spécifiquement sur l'état "0 page" créent leur propre support brut
|
||||||
|
via db.create_support(...) plutôt que d'utiliser cette fixture."""
|
||||||
import db.supports
|
import db.supports
|
||||||
|
import document_engine
|
||||||
|
|
||||||
resp = client.post("/documents/new", data={"name": "pytest_test_support"}, follow_redirects=False)
|
resp = client.post("/documents/new", data={"name": "pytest_test_support"}, follow_redirects=False)
|
||||||
assert resp.status_code == 302
|
assert resp.status_code == 302
|
||||||
slug = resp.headers["Location"].rstrip("/").split("/")[-2]
|
slug = resp.headers["Location"].rstrip("/").split("/")[-2]
|
||||||
|
document_engine.add_document_page(slug)
|
||||||
yield slug
|
yield slug
|
||||||
if os.path.isdir(db.supports.support_dir(slug)):
|
if os.path.isdir(db.supports.support_dir(slug)):
|
||||||
db.delete_support(slug)
|
db.delete_support(slug)
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
"""Modèle de données du mini-jeu Association
|
||||||
|
(document_engine/labels/association_config.py) — sans Flask, teste
|
||||||
|
directement sanitize_association_config, et le rendu du plateau."""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import document_engine as doc_engine
|
||||||
|
|
||||||
|
|
||||||
|
def test_sanitize_association_config_on_missing_input_returns_full_defaults() -> None:
|
||||||
|
assert doc_engine.sanitize_association_config(None) == {"theme_color": "#ff5f2e", "pairs": []}
|
||||||
|
|
||||||
|
|
||||||
|
def test_sanitize_association_config_keeps_valid_pairs() -> None:
|
||||||
|
raw = {"theme_color": "#123456", "pairs": [{"left": "Chat", "right": "Un félin"}]}
|
||||||
|
config = doc_engine.sanitize_association_config(raw)
|
||||||
|
assert config["theme_color"] == "#123456"
|
||||||
|
assert config["pairs"] == [{"left": "Chat", "right": "Un félin"}]
|
||||||
|
|
||||||
|
|
||||||
|
def test_sanitize_association_config_drops_a_pair_missing_either_side() -> None:
|
||||||
|
raw = {"pairs": [{"left": "Chat", "right": " "}, {"left": " ", "right": "Un félin"}]}
|
||||||
|
assert doc_engine.sanitize_association_config(raw)["pairs"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_sanitize_association_config_caps_pairs_at_max() -> None:
|
||||||
|
raw = {"pairs": [{"left": f"L{i}", "right": f"R{i}"} for i in range(doc_engine.MAX_PAIRS + 3)]}
|
||||||
|
config = doc_engine.sanitize_association_config(raw)
|
||||||
|
assert len(config["pairs"]) == doc_engine.MAX_PAIRS
|
||||||
|
|
||||||
|
|
||||||
|
def test_sanitize_association_config_ignores_garbage_top_level_input() -> None:
|
||||||
|
assert doc_engine.sanitize_association_config("n'importe quoi") == doc_engine.DEFAULT_ASSOCIATION_CONFIG
|
||||||
|
|
||||||
|
|
||||||
|
def _association_element(attributes: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return {"id": 1, "kind": "association", "parent_id": None, "order_index": 0, "attributes": attributes}
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_association_without_pairs_shows_only_the_badge() -> None:
|
||||||
|
html = doc_engine.render_document_element(_association_element(doc_engine.DEFAULT_ASSOCIATION_CONFIG), {})
|
||||||
|
assert "0 paire" in html
|
||||||
|
assert "docAssocPlayer" not in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_association_with_pairs_includes_the_interactive_board() -> None:
|
||||||
|
config = doc_engine.sanitize_association_config(
|
||||||
|
{"pairs": [{"left": "Chat", "right": "Un félin"}, {"left": "Chien", "right": "Un canidé"}]}
|
||||||
|
)
|
||||||
|
html = doc_engine.render_document_element(_association_element(config), {})
|
||||||
|
assert "docAssocPlayer" in html
|
||||||
|
assert "data-assoc-config=" in html
|
||||||
|
assert "2 paires" in html
|
||||||
|
# Les deux colonnes sont mélangées indépendamment côté rendu (voir
|
||||||
|
# _render_association_player) : impossible d'attendre un ORDRE précis,
|
||||||
|
# seulement que le contenu des deux côtés est bien présent quelque part.
|
||||||
|
assert "Chat" in html
|
||||||
|
assert "Un f\\u00e9lin" in html or "Un félin" in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_association_escapes_pair_text_in_embedded_json() -> None:
|
||||||
|
config = doc_engine.sanitize_association_config(
|
||||||
|
{"pairs": [{"left": '"><script>alert(1)</script>', "right": "def"}, {"left": "abc", "right": "def2"}]}
|
||||||
|
)
|
||||||
|
html = doc_engine.render_document_element(_association_element(config), {})
|
||||||
|
assert "<script>alert(1)</script>" not in html
|
||||||
|
assert "<script>" in html
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
"""Attributs de "boîte" partagés par plusieurs kinds de contenu (voir
|
||||||
|
document_engine/rendering/box_style.py) — testés une seule fois ici,
|
||||||
|
indépendamment de chaque kind qui les utilise (voir aussi
|
||||||
|
test_document_elements.py pour leur usage réel via _render_text)."""
|
||||||
|
|
||||||
|
from document_engine.rendering.box_style import BORDER_SIDES, default_border, render_box_style, render_content_align
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_border_has_all_four_sides_set_to_none() -> None:
|
||||||
|
border = default_border()
|
||||||
|
assert set(border.keys()) == set(BORDER_SIDES)
|
||||||
|
for side in BORDER_SIDES:
|
||||||
|
assert border[side]["style"] == "none"
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_border_returns_a_fresh_dict_each_call() -> None:
|
||||||
|
"""Jamais un littéral partagé muté par référence entre deux éléments
|
||||||
|
(même précaution que DEFAULT_QUIZ_CONFIG côté labels)."""
|
||||||
|
a = default_border()
|
||||||
|
b = default_border()
|
||||||
|
a["top"]["style"] = "solid"
|
||||||
|
assert b["top"]["style"] == "none"
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_box_style_returns_empty_string_for_all_defaults() -> None:
|
||||||
|
assert render_box_style({}) == ""
|
||||||
|
assert render_box_style({"border": default_border(), "align_self": "stretch"}) == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_box_style_includes_padding_margin_background_and_radius() -> None:
|
||||||
|
style = render_box_style(
|
||||||
|
{"padding": "12px", "margin": "0 auto", "background_color": "#eee", "border_radius": "8px"}
|
||||||
|
)
|
||||||
|
assert "padding:12px;" in style
|
||||||
|
assert "margin:0 auto;" in style
|
||||||
|
assert "background-color:#eee;" in style
|
||||||
|
assert "border-radius:8px;" in style
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_box_style_includes_sizing_shadow_and_opacity() -> None:
|
||||||
|
style = render_box_style(
|
||||||
|
{
|
||||||
|
"height": "200px",
|
||||||
|
"min_height": "100px",
|
||||||
|
"max_height": "400px",
|
||||||
|
"min_width": "50px",
|
||||||
|
"box_shadow": "0 4px 12px rgba(0,0,0,.2)",
|
||||||
|
"opacity": "0.8",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert "height:200px;" in style
|
||||||
|
assert "min-height:100px;" in style
|
||||||
|
assert "max-height:400px;" in style
|
||||||
|
assert "min-width:50px;" in style
|
||||||
|
assert "box-shadow:0 4px 12px rgba(0,0,0,.2);" in style
|
||||||
|
assert "opacity:0.8;" in style
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_box_style_includes_align_self_only_when_not_stretch() -> None:
|
||||||
|
assert "align-self" not in render_box_style({"align_self": "stretch"})
|
||||||
|
assert "align-self:center;" in render_box_style({"align_self": "center"})
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_box_style_renders_only_sides_with_a_non_none_style() -> None:
|
||||||
|
border = default_border()
|
||||||
|
border["top"] = {"style": "solid", "width": "2px", "color": "#ff0000"}
|
||||||
|
style = render_box_style({"border": border})
|
||||||
|
assert "border-top:2px solid #ff0000;" in style
|
||||||
|
assert "border-right" not in style
|
||||||
|
assert "border-bottom" not in style
|
||||||
|
assert "border-left" not in style
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_box_style_escapes_malicious_values() -> None:
|
||||||
|
border = default_border()
|
||||||
|
border["top"] = {"style": "solid", "width": "1px", "color": '"><script>alert(1)</script>'}
|
||||||
|
style = render_box_style({"padding": '"><script>alert(2)</script>', "border": border})
|
||||||
|
assert "<script>" not in style
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_content_align_is_empty_for_default_top() -> None:
|
||||||
|
# Retour utilisateur du 26/09/2026 : "je peux augmenter la hauteur
|
||||||
|
# d'un conteneur mais pas l'alignement vertical à l'intérieur" —
|
||||||
|
# "top" est le comportement historique (contenu en haut), jamais un
|
||||||
|
# style ajouté pour ne rien changer par défaut.
|
||||||
|
assert render_content_align({}) == ""
|
||||||
|
assert render_content_align({"content_align": "top"}) == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_content_align_renders_center_and_bottom() -> None:
|
||||||
|
assert render_content_align({"content_align": "center"}) == "justify-content:center;"
|
||||||
|
assert render_content_align({"content_align": "bottom"}) == "justify-content:flex-end;"
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_content_align_ignores_unknown_values() -> None:
|
||||||
|
assert render_content_align({"content_align": "n-importe-quoi"}) == ""
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,145 @@
|
|||||||
|
"""Routes HTTP des pages d'un support de formation (routes/document/
|
||||||
|
document_page_*.py) — retour utilisateur du 21/09/2026 : "il faut
|
||||||
|
implémenter un système de page". La fixture `support` crée une première
|
||||||
|
page par convénience pour ces tests (voir tests/conftest.py) ; en
|
||||||
|
production un support neuf n'a plus aucune page, et supprimer la
|
||||||
|
dernière page restante est désormais autorisé (retour utilisateur du
|
||||||
|
26/09/2026, voir document_engine/pages/pages.md)."""
|
||||||
|
|
||||||
|
from flask.testing import FlaskClient
|
||||||
|
|
||||||
|
import document_engine
|
||||||
|
|
||||||
|
|
||||||
|
def _page_id(support: str) -> int:
|
||||||
|
return int(document_engine.list_document_pages(support)[0]["id"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_document_page_add_creates_a_second_page(client: FlaskClient, support: str) -> None:
|
||||||
|
resp = client.post(f"/document/{support}/pages/add", json={})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
payload = resp.get_json()
|
||||||
|
assert payload["title"] == "Page 2"
|
||||||
|
|
||||||
|
pages = document_engine.list_document_pages(support)
|
||||||
|
assert [p["title"] for p in pages] == ["Page 1", "Page 2"]
|
||||||
|
assert pages[1]["id"] == payload["id"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_document_page_add_accepts_a_custom_title(client: FlaskClient, support: str) -> None:
|
||||||
|
resp = client.post(f"/document/{support}/pages/add", json={"title": "Chapitre 2"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.get_json()["title"] == "Chapitre 2"
|
||||||
|
|
||||||
|
|
||||||
|
def test_document_page_rename_persists_the_new_title(client: FlaskClient, support: str) -> None:
|
||||||
|
page_id = _page_id(support)
|
||||||
|
resp = client.post(f"/document/{support}/pages/{page_id}/rename", json={"title": "Introduction"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.get_json() == {"ok": True, "title": "Introduction"}
|
||||||
|
page = document_engine.get_document_page(support, page_id)
|
||||||
|
assert page is not None
|
||||||
|
assert page["title"] == "Introduction"
|
||||||
|
|
||||||
|
|
||||||
|
def test_document_page_rename_ignores_an_empty_title(client: FlaskClient, support: str) -> None:
|
||||||
|
page_id = _page_id(support)
|
||||||
|
resp = client.post(f"/document/{support}/pages/{page_id}/rename", json={"title": " "})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.get_json()["title"] == "Page 1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_document_page_rename_rejects_an_unknown_page(client: FlaskClient, support: str) -> None:
|
||||||
|
assert client.post(f"/document/{support}/pages/999/rename", json={"title": "X"}).status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_document_page_delete_allows_deleting_the_last_remaining_page(client: FlaskClient, support: str) -> None:
|
||||||
|
page_id = _page_id(support)
|
||||||
|
resp = client.post(f"/document/{support}/pages/{page_id}/delete")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.get_json()["ok"] is True
|
||||||
|
assert document_engine.get_document_page(support, page_id) is None
|
||||||
|
assert document_engine.list_document_pages(support) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_document_page_delete_all_wipes_every_page_and_its_elements(client: FlaskClient, support: str) -> None:
|
||||||
|
first_page_id = _page_id(support)
|
||||||
|
second_page_id = client.post(f"/document/{support}/pages/add", json={}).get_json()["id"]
|
||||||
|
element_resp = client.post(f"/document/{support}/elements/add", data={"kind": "titre", "page_id": second_page_id})
|
||||||
|
element_id = element_resp.get_json()["id"]
|
||||||
|
|
||||||
|
resp = client.post(f"/document/{support}/pages/delete-all")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.get_json()["ok"] is True
|
||||||
|
|
||||||
|
assert document_engine.list_document_pages(support) == []
|
||||||
|
assert document_engine.get_document_page(support, first_page_id) is None
|
||||||
|
assert document_engine.get_document_page(support, second_page_id) is None
|
||||||
|
assert document_engine.get_document_element(support, element_id) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_document_page_delete_removes_a_non_last_page_and_its_elements(client: FlaskClient, support: str) -> None:
|
||||||
|
first_page_id = _page_id(support)
|
||||||
|
second_page_id = client.post(f"/document/{support}/pages/add", json={}).get_json()["id"]
|
||||||
|
element_resp = client.post(f"/document/{support}/elements/add", data={"kind": "titre", "page_id": second_page_id})
|
||||||
|
element_id = element_resp.get_json()["id"]
|
||||||
|
|
||||||
|
resp = client.post(f"/document/{support}/pages/{second_page_id}/delete")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.get_json()["ok"] is True
|
||||||
|
|
||||||
|
assert document_engine.get_document_page(support, second_page_id) is None
|
||||||
|
assert document_engine.get_document_element(support, element_id) is None
|
||||||
|
assert [p["id"] for p in document_engine.list_document_pages(support)] == [first_page_id]
|
||||||
|
|
||||||
|
|
||||||
|
def test_document_page_delete_rejects_an_unknown_page(client: FlaskClient, support: str) -> None:
|
||||||
|
client.post(f"/document/{support}/pages/add", json={})
|
||||||
|
assert client.post(f"/document/{support}/pages/999/delete").status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_document_page_move_reorders_the_tab_strip(client: FlaskClient, support: str) -> None:
|
||||||
|
first_page_id = _page_id(support)
|
||||||
|
second_page_id = client.post(f"/document/{support}/pages/add", json={}).get_json()["id"]
|
||||||
|
third_page_id = client.post(f"/document/{support}/pages/add", json={}).get_json()["id"]
|
||||||
|
|
||||||
|
resp = client.post(f"/document/{support}/pages/{third_page_id}/move", json={"new_index": 0})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.get_json()["ok"] is True
|
||||||
|
|
||||||
|
ordered_ids = [p["id"] for p in document_engine.list_document_pages(support)]
|
||||||
|
assert ordered_ids == [third_page_id, first_page_id, second_page_id]
|
||||||
|
|
||||||
|
|
||||||
|
def test_document_page_move_rejects_an_unknown_page(client: FlaskClient, support: str) -> None:
|
||||||
|
assert client.post(f"/document/{support}/pages/999/move", json={"new_index": 0}).status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_new_page_defaults_to_top_vertical_align(client: FlaskClient, support: str) -> None:
|
||||||
|
page_id = _page_id(support)
|
||||||
|
page = document_engine.get_document_page(support, page_id)
|
||||||
|
assert page is not None
|
||||||
|
assert page["vertical_align"] == "top"
|
||||||
|
|
||||||
|
|
||||||
|
def test_document_page_vertical_align_persists_a_valid_value(client: FlaskClient, support: str) -> None:
|
||||||
|
page_id = _page_id(support)
|
||||||
|
resp = client.post(f"/document/{support}/pages/{page_id}/vertical-align", json={"vertical_align": "center"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.get_json() == {"ok": True, "vertical_align": "center"}
|
||||||
|
page = document_engine.get_document_page(support, page_id)
|
||||||
|
assert page is not None
|
||||||
|
assert page["vertical_align"] == "center"
|
||||||
|
|
||||||
|
|
||||||
|
def test_document_page_vertical_align_falls_back_to_top_for_an_invalid_value(client: FlaskClient, support: str) -> None:
|
||||||
|
page_id = _page_id(support)
|
||||||
|
client.post(f"/document/{support}/pages/{page_id}/vertical-align", json={"vertical_align": "center"})
|
||||||
|
resp = client.post(f"/document/{support}/pages/{page_id}/vertical-align", json={"vertical_align": "n-importe-quoi"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.get_json()["vertical_align"] == "top"
|
||||||
|
|
||||||
|
|
||||||
|
def test_document_page_vertical_align_rejects_an_unknown_page(client: FlaskClient, support: str) -> None:
|
||||||
|
resp = client.post(f"/document/{support}/pages/999/vertical-align", json={"vertical_align": "center"})
|
||||||
|
assert resp.status_code == 404
|
||||||
@@ -1,13 +1,25 @@
|
|||||||
"""Routes HTTP du support de formation (routes/document/) — création,
|
"""Routes HTTP du support de formation (routes/document/) — création,
|
||||||
édition directe (sans page intermédiaire), CRUD des éléments en AJAX,
|
édition directe (sans page intermédiaire), CRUD des éléments en AJAX,
|
||||||
publication, et isolation par propriétaire (core/auth_guard.py)."""
|
publication, et isolation par propriétaire (core/auth_guard.py).
|
||||||
|
|
||||||
|
Chaque élément appartient désormais à une page (voir document_engine/
|
||||||
|
pages/ et test_document_pages_routes.py pour les routes dédiées aux
|
||||||
|
pages elles-mêmes) — _page_id() renvoie l'id de la page par défaut
|
||||||
|
("Page 1") créée avec chaque support de test, réutilisé par toutes les
|
||||||
|
routes d'élément ci-dessous qui exigent désormais un page_id explicite."""
|
||||||
|
|
||||||
|
import io
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from flask.testing import FlaskClient
|
from flask.testing import FlaskClient
|
||||||
|
|
||||||
import auth
|
import auth
|
||||||
import db
|
import db
|
||||||
|
import document_engine
|
||||||
|
|
||||||
|
|
||||||
|
def _page_id(support: str) -> int:
|
||||||
|
return int(document_engine.list_document_pages(support)[0]["id"])
|
||||||
|
|
||||||
|
|
||||||
def test_documents_new_creates_a_support_not_a_game(client: FlaskClient) -> None:
|
def test_documents_new_creates_a_support_not_a_game(client: FlaskClient) -> None:
|
||||||
@@ -27,8 +39,21 @@ def test_document_edit_renders_directly_no_intermediate_page(client: FlaskClient
|
|||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_document_edit_works_on_a_support_with_zero_pages(client: FlaskClient, support: str) -> None:
|
||||||
|
# Retour utilisateur du 26/09/2026 : "l'éditeur ne dois plus etre
|
||||||
|
# obliger d'avoir une page active ou créer, il peut etre ouvert sans
|
||||||
|
# aucune page" — un support neuf (ou vidé via "Supprimer toutes les
|
||||||
|
# pages") n'a plus de page active, l'éditeur ne doit pas planter.
|
||||||
|
client.post(f"/document/{support}/pages/delete-all")
|
||||||
|
resp = client.get(f"/document/{support}/edit")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
html = resp.get_data(as_text=True)
|
||||||
|
assert "activePageId: null" in html
|
||||||
|
assert "pages: []" in html
|
||||||
|
|
||||||
|
|
||||||
def test_document_element_add_returns_rendered_html(client: FlaskClient, support: str) -> None:
|
def test_document_element_add_returns_rendered_html(client: FlaskClient, support: str) -> None:
|
||||||
resp = client.post(f"/document/{support}/elements/add", data={"kind": "titre"})
|
resp = client.post(f"/document/{support}/elements/add", data={"kind": "titre", "page_id": _page_id(support)})
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
payload = resp.get_json()
|
payload = resp.get_json()
|
||||||
assert payload["kind"] == "titre"
|
assert payload["kind"] == "titre"
|
||||||
@@ -36,7 +61,15 @@ def test_document_element_add_returns_rendered_html(client: FlaskClient, support
|
|||||||
|
|
||||||
|
|
||||||
def test_document_element_add_rejects_unknown_kind(client: FlaskClient, support: str) -> None:
|
def test_document_element_add_rejects_unknown_kind(client: FlaskClient, support: str) -> None:
|
||||||
assert client.post(f"/document/{support}/elements/add", data={"kind": "n-importe-quoi"}).status_code == 400
|
resp = client.post(
|
||||||
|
f"/document/{support}/elements/add", data={"kind": "n-importe-quoi", "page_id": _page_id(support)}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_document_element_add_rejects_a_missing_or_unknown_page(client: FlaskClient, support: str) -> None:
|
||||||
|
assert client.post(f"/document/{support}/elements/add", data={"kind": "titre"}).status_code == 404
|
||||||
|
assert client.post(f"/document/{support}/elements/add", data={"kind": "titre", "page_id": 999}).status_code == 404
|
||||||
|
|
||||||
|
|
||||||
def test_document_element_add_accepts_row_for_the_layout_engine(client: FlaskClient, support: str) -> None:
|
def test_document_element_add_accepts_row_for_the_layout_engine(client: FlaskClient, support: str) -> None:
|
||||||
@@ -45,13 +78,33 @@ def test_document_element_add_accepts_row_for_the_layout_engine(client: FlaskCli
|
|||||||
route — c'est le moteur de layout côté client qui en crée une à la
|
route — c'est le moteur de layout côté client qui en crée une à la
|
||||||
volée au moment d'un dépôt latéral (voir
|
volée au moment d'un dépôt latéral (voir
|
||||||
static/document/js/document-editor.js)."""
|
static/document/js/document-editor.js)."""
|
||||||
resp = client.post(f"/document/{support}/elements/add", data={"kind": "row"})
|
resp = client.post(f"/document/{support}/elements/add", data={"kind": "row", "page_id": _page_id(support)})
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert resp.get_json()["kind"] == "row"
|
assert resp.get_json()["kind"] == "row"
|
||||||
|
|
||||||
|
|
||||||
|
def test_document_element_add_creates_a_bullet_list(client: FlaskClient, support: str) -> None:
|
||||||
|
resp = client.post(f"/document/{support}/elements/add", data={"kind": "liste_puces", "page_id": _page_id(support)})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
payload = resp.get_json()
|
||||||
|
assert payload["kind"] == "liste_puces"
|
||||||
|
assert "<ul" in payload["rendered_html"]
|
||||||
|
assert "docList" in payload["rendered_html"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_document_element_add_creates_a_numbered_list(client: FlaskClient, support: str) -> None:
|
||||||
|
resp = client.post(
|
||||||
|
f"/document/{support}/elements/add", data={"kind": "liste_numerotee", "page_id": _page_id(support)}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
payload = resp.get_json()
|
||||||
|
assert payload["kind"] == "liste_numerotee"
|
||||||
|
assert "<ol" in payload["rendered_html"]
|
||||||
|
|
||||||
|
|
||||||
def test_document_element_update_persists_attributes(client: FlaskClient, support: str) -> None:
|
def test_document_element_update_persists_attributes(client: FlaskClient, support: str) -> None:
|
||||||
add_resp = client.post(f"/document/{support}/elements/add", data={"kind": "paragraphe"})
|
page_id = _page_id(support)
|
||||||
|
add_resp = client.post(f"/document/{support}/elements/add", data={"kind": "paragraphe", "page_id": page_id})
|
||||||
element_id = add_resp.get_json()["id"]
|
element_id = add_resp.get_json()["id"]
|
||||||
|
|
||||||
resp = client.post(
|
resp = client.post(
|
||||||
@@ -61,17 +114,17 @@ def test_document_element_update_persists_attributes(client: FlaskClient, suppor
|
|||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert resp.get_json()["ok"] is True
|
assert resp.get_json()["ok"] is True
|
||||||
|
|
||||||
reread = client.post(f"/document/{support}/elements/add", data={"kind": "titre"})
|
reread = client.post(f"/document/{support}/elements/add", data={"kind": "titre", "page_id": page_id})
|
||||||
assert reread.status_code == 200
|
assert reread.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
def test_document_element_update_sanitizes_quiz_config_on_write(client: FlaskClient, support: str) -> None:
|
def test_document_element_update_sanitizes_quiz_config_on_write(client: FlaskClient, support: str) -> None:
|
||||||
"""Le quiz est le seul kind revalidé côté serveur (voir
|
"""Quiz et Association sont revalidés côté serveur (voir
|
||||||
routes/document/document_element_update.py) : un choix en trop
|
routes/document/document_element_update.py) : un choix en trop
|
||||||
(5e) doit être tronqué, et la réponse renvoie les attributs
|
(5e) doit être tronqué, et la réponse renvoie les attributs
|
||||||
RÉELLEMENT persistés (pas ceux envoyés tels quels) pour que le
|
RÉELLEMENT persistés (pas ceux envoyés tels quels) pour que le
|
||||||
client ne dérive jamais de la vérité serveur."""
|
client ne dérive jamais de la vérité serveur."""
|
||||||
add_resp = client.post(f"/document/{support}/elements/add", data={"kind": "quiz"})
|
add_resp = client.post(f"/document/{support}/elements/add", data={"kind": "quiz", "page_id": _page_id(support)})
|
||||||
element_id = add_resp.get_json()["id"]
|
element_id = add_resp.get_json()["id"]
|
||||||
|
|
||||||
resp = client.post(
|
resp = client.post(
|
||||||
@@ -90,12 +143,162 @@ def test_document_element_update_sanitizes_quiz_config_on_write(client: FlaskCli
|
|||||||
assert payload["ok"] is True
|
assert payload["ok"] is True
|
||||||
assert payload["attributes"]["questions"][0]["choices"] == ["A", "B", "C", "D"]
|
assert payload["attributes"]["questions"][0]["choices"] == ["A", "B", "C", "D"]
|
||||||
assert "1 question" in payload["rendered_html"]
|
assert "1 question" in payload["rendered_html"]
|
||||||
assert "10 points" in payload["rendered_html"]
|
|
||||||
|
|
||||||
|
def test_document_element_update_sanitizes_association_config_on_write(client: FlaskClient, support: str) -> None:
|
||||||
|
add_resp = client.post(
|
||||||
|
f"/document/{support}/elements/add", data={"kind": "association", "page_id": _page_id(support)}
|
||||||
|
)
|
||||||
|
element_id = add_resp.get_json()["id"]
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
f"/document/{support}/elements/{element_id}/update",
|
||||||
|
json={
|
||||||
|
"theme_color": "#ffb020",
|
||||||
|
"pairs": [
|
||||||
|
{"left": "Chat", "right": "Un félin"},
|
||||||
|
{"left": "Sans correspondance", "right": ""},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
payload = resp.get_json()
|
||||||
|
assert payload["ok"] is True
|
||||||
|
assert payload["attributes"]["pairs"] == [{"left": "Chat", "right": "Un félin"}]
|
||||||
|
assert "1 paire" in payload["rendered_html"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_document_element_update_sanitizes_memory_config_on_write(client: FlaskClient, support: str) -> None:
|
||||||
|
add_resp = client.post(f"/document/{support}/elements/add", data={"kind": "memory", "page_id": _page_id(support)})
|
||||||
|
element_id = add_resp.get_json()["id"]
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
f"/document/{support}/elements/{element_id}/update",
|
||||||
|
json={
|
||||||
|
"theme_color": "#ffb020",
|
||||||
|
"mode": "single",
|
||||||
|
"cards": [
|
||||||
|
{"recto": {"text": "?"}, "verso": {"text": "Chat"}},
|
||||||
|
{"recto": {"text": "?"}, "verso": {"image": "", "text": ""}},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
payload = resp.get_json()
|
||||||
|
assert payload["ok"] is True
|
||||||
|
assert payload["attributes"]["mode"] == "single"
|
||||||
|
assert len(payload["attributes"]["cards"]) == 1
|
||||||
|
assert "1 carte" in payload["rendered_html"]
|
||||||
|
assert "mode simple" in payload["rendered_html"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_document_edit_sanitizes_stale_scenario_attributes_from_an_old_schema(
|
||||||
|
client: FlaskClient, support: str
|
||||||
|
) -> None:
|
||||||
|
"""Bug réel constaté le 21/09/2026 : un élément Scénario créé AVANT la
|
||||||
|
refonte en arbre de décision (ancien schéma plat "situation"/"choices"/
|
||||||
|
"correct_index", sans "title"/"nodes") faisait planter silencieusement
|
||||||
|
le panneau Propriétés côté client (`scenario.nodes` inexistant), sans
|
||||||
|
qu'aucune erreur ne remonte — voir document_engine/labels/
|
||||||
|
sanitize_element_attributes.py. `update_document_element_attributes`
|
||||||
|
(bas niveau, jamais utilisée directement par une route) sert ici à
|
||||||
|
écrire ce schéma obsolète tel quel, en contournant volontairement le
|
||||||
|
sanitize de la route d'update — pour simuler une ligne réellement
|
||||||
|
ancienne en base, jamais nettoyée depuis."""
|
||||||
|
page_id = _page_id(support)
|
||||||
|
add_resp = client.post(f"/document/{support}/elements/add", data={"kind": "scenario", "page_id": page_id})
|
||||||
|
element_id = add_resp.get_json()["id"]
|
||||||
|
stale_attributes = {
|
||||||
|
"theme_color": "#ff5f2e",
|
||||||
|
"scenarios": [
|
||||||
|
{
|
||||||
|
"situation": "Un chat va se faire renverser sous vos yeux, que faites-vous ?",
|
||||||
|
"choices": [{"text": "Je le sauve", "consequence": "Le chat est sauvé."}],
|
||||||
|
"correct_index": 0,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
document_engine.update_document_element_attributes(support, element_id, stale_attributes)
|
||||||
|
|
||||||
|
edit_resp = client.get(f"/document/{support}/edit")
|
||||||
|
assert edit_resp.status_code == 200
|
||||||
|
render_resp = client.get(f"/document/{support}/render", query_string={"page_id": page_id})
|
||||||
|
assert render_resp.status_code == 200
|
||||||
|
rendered_element = next(el for el in render_resp.get_json()["elements"] if el["id"] == element_id)
|
||||||
|
# Le vieux schéma n'a ni "title" ni "nodes" valides : sanitize_scenario_config
|
||||||
|
# le rejette entièrement plutôt que de renvoyer une structure à moitié
|
||||||
|
# ancienne/à moitié nouvelle — c'est la structure ACTUELLE garantie qui
|
||||||
|
# compte ici, jamais un plantage silencieux côté client.
|
||||||
|
assert rendered_element["attributes"] == document_engine.DEFAULT_SCENARIO_CONFIG
|
||||||
|
|
||||||
|
|
||||||
|
def test_document_render_rejects_a_missing_or_unknown_page(client: FlaskClient, support: str) -> None:
|
||||||
|
assert client.get(f"/document/{support}/render").status_code == 404
|
||||||
|
assert client.get(f"/document/{support}/render", query_string={"page_id": 999}).status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_document_render_includes_the_page_vertical_align(client: FlaskClient, support: str) -> None:
|
||||||
|
page_id = _page_id(support)
|
||||||
|
client.post(f"/document/{support}/pages/{page_id}/vertical-align", json={"vertical_align": "center"})
|
||||||
|
resp = client.get(f"/document/{support}/render", query_string={"page_id": page_id})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.get_json()["vertical_align"] == "center"
|
||||||
|
|
||||||
|
|
||||||
|
def test_document_edit_reflects_the_active_page_vertical_align(client: FlaskClient, support: str) -> None:
|
||||||
|
page_id = _page_id(support)
|
||||||
|
client.post(f"/document/{support}/pages/{page_id}/vertical-align", json={"vertical_align": "center"})
|
||||||
|
resp = client.get(f"/document/{support}/edit")
|
||||||
|
assert 'data-vertical-align="center"' in resp.get_data(as_text=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_document_element_add_rejects_a_minigame_on_a_non_empty_page(client: FlaskClient, support: str) -> None:
|
||||||
|
"""Retour utilisateur du 23/09/2026 : "un mini jeu dois occupper
|
||||||
|
toute une page" -> "une page avec mini-jeu = uniquement ce
|
||||||
|
mini-jeu". Un mini-jeu ne peut donc jamais rejoindre une page qui a
|
||||||
|
déjà du contenu."""
|
||||||
|
page_id = _page_id(support)
|
||||||
|
client.post(f"/document/{support}/elements/add", data={"kind": "titre", "page_id": page_id})
|
||||||
|
|
||||||
|
resp = client.post(f"/document/{support}/elements/add", data={"kind": "quiz", "page_id": page_id})
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert "mini-jeu" in resp.get_json()["error"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_document_element_add_rejects_other_content_on_a_page_with_a_minigame(
|
||||||
|
client: FlaskClient, support: str
|
||||||
|
) -> None:
|
||||||
|
page_id = _page_id(support)
|
||||||
|
client.post(f"/document/{support}/elements/add", data={"kind": "quiz", "page_id": page_id})
|
||||||
|
|
||||||
|
resp = client.post(f"/document/{support}/elements/add", data={"kind": "titre", "page_id": page_id})
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert "mini-jeu" in resp.get_json()["error"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_document_element_add_rejects_a_minigame_inside_a_row(client: FlaskClient, support: str) -> None:
|
||||||
|
page_id = _page_id(support)
|
||||||
|
row_id = client.post(f"/document/{support}/elements/add", data={"kind": "row", "page_id": page_id}).get_json()["id"]
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
f"/document/{support}/elements/add", data={"kind": "quiz", "page_id": page_id, "parent_id": row_id}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_document_element_add_allows_a_lone_minigame_on_an_empty_page(client: FlaskClient, support: str) -> None:
|
||||||
|
resp = client.post(f"/document/{support}/elements/add", data={"kind": "quiz", "page_id": _page_id(support)})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
def test_document_element_move_and_delete(client: FlaskClient, support: str) -> None:
|
def test_document_element_move_and_delete(client: FlaskClient, support: str) -> None:
|
||||||
first_id = client.post(f"/document/{support}/elements/add", data={"kind": "titre"}).get_json()["id"]
|
page_id = _page_id(support)
|
||||||
second_id = client.post(f"/document/{support}/elements/add", data={"kind": "paragraphe"}).get_json()["id"]
|
first_id = client.post(f"/document/{support}/elements/add", data={"kind": "titre", "page_id": page_id}).get_json()[
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
second_id = client.post(
|
||||||
|
f"/document/{support}/elements/add", data={"kind": "paragraphe", "page_id": page_id}
|
||||||
|
).get_json()["id"]
|
||||||
|
|
||||||
move_resp = client.post(
|
move_resp = client.post(
|
||||||
f"/document/{support}/elements/{second_id}/move",
|
f"/document/{support}/elements/{second_id}/move",
|
||||||
@@ -109,6 +312,35 @@ def test_document_element_move_and_delete(client: FlaskClient, support: str) ->
|
|||||||
assert delete_resp.get_json()["ok"] is True
|
assert delete_resp.get_json()["ok"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_document_element_move_to_page_transfers_the_element(client: FlaskClient, support: str) -> None:
|
||||||
|
"""Retour utilisateur du 23/09/2026 : pagination automatique — un
|
||||||
|
élément qui déborde d'une page est déplacé vers une autre, ici la
|
||||||
|
route qui porte ce déplacement (jamais document_element_move, qui
|
||||||
|
ne fait que réordonner DANS la même page)."""
|
||||||
|
page1_id = _page_id(support)
|
||||||
|
page2_id = document_engine.add_document_page(support)
|
||||||
|
element_id = client.post(
|
||||||
|
f"/document/{support}/elements/add", data={"kind": "titre", "page_id": page1_id}
|
||||||
|
).get_json()["id"]
|
||||||
|
|
||||||
|
resp = client.post(f"/document/{support}/elements/{element_id}/move-to-page", json={"target_page_id": page2_id})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.get_json()["ok"] is True
|
||||||
|
moved = document_engine.get_document_element(support, element_id)
|
||||||
|
assert moved is not None
|
||||||
|
assert moved["page_id"] == page2_id
|
||||||
|
|
||||||
|
|
||||||
|
def test_document_element_move_to_page_rejects_an_unknown_target(client: FlaskClient, support: str) -> None:
|
||||||
|
page_id = _page_id(support)
|
||||||
|
element_id = client.post(
|
||||||
|
f"/document/{support}/elements/add", data={"kind": "titre", "page_id": page_id}
|
||||||
|
).get_json()["id"]
|
||||||
|
|
||||||
|
resp = client.post(f"/document/{support}/elements/{element_id}/move-to-page", json={"target_page_id": 999})
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
def test_restricted_user_can_have_one_game_and_one_support_at_once(
|
def test_restricted_user_can_have_one_game_and_one_support_at_once(
|
||||||
user_client: FlaskClient, tmp_game_slug_cleanup: Any, tmp_support_slug_cleanup: Any
|
user_client: FlaskClient, tmp_game_slug_cleanup: Any, tmp_support_slug_cleanup: Any
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -141,3 +373,229 @@ def test_cannot_open_another_owners_support(client: FlaskClient, user_client: Fl
|
|||||||
assert client.post(f"/document/{victim_slug}/delete").status_code == 403
|
assert client.post(f"/document/{victim_slug}/delete").status_code == 403
|
||||||
finally:
|
finally:
|
||||||
db.delete_support(victim_slug)
|
db.delete_support(victim_slug)
|
||||||
|
|
||||||
|
|
||||||
|
def _add_bouton(client: FlaskClient, support: str) -> int:
|
||||||
|
resp = client.post(f"/document/{support}/elements/add", data={"kind": "bouton", "page_id": _page_id(support)})
|
||||||
|
return int(resp.get_json()["id"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_attachment_stores_file_and_updates_button_attributes(client: FlaskClient, support: str) -> None:
|
||||||
|
element_id = _add_bouton(client, support)
|
||||||
|
resp = client.post(
|
||||||
|
f"/document/{support}/elements/{element_id}/upload-attachment",
|
||||||
|
data={"file": (io.BytesIO(b"%PDF-1.4 fake pdf content"), "fiche-consignes.pdf")},
|
||||||
|
content_type="multipart/form-data",
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
payload = resp.get_json()
|
||||||
|
assert payload["attributes"]["attachment_filename"] == "fiche-consignes.pdf"
|
||||||
|
assert payload["attributes"]["attachment_stored_name"]
|
||||||
|
assert 'data-attachment-filename="fiche-consignes.pdf"' in payload["rendered_html"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_attachment_rejects_a_non_bouton_element(client: FlaskClient, support: str) -> None:
|
||||||
|
resp = client.post(f"/document/{support}/elements/add", data={"kind": "titre", "page_id": _page_id(support)})
|
||||||
|
element_id = resp.get_json()["id"]
|
||||||
|
resp = client.post(
|
||||||
|
f"/document/{support}/elements/{element_id}/upload-attachment",
|
||||||
|
data={"file": (io.BytesIO(b"peu importe"), "x.pdf")},
|
||||||
|
content_type="multipart/form-data",
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_attachment_rejects_a_missing_file(client: FlaskClient, support: str) -> None:
|
||||||
|
element_id = _add_bouton(client, support)
|
||||||
|
resp = client.post(f"/document/{support}/elements/{element_id}/upload-attachment", data={})
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_download_attachment_serves_the_file_under_its_original_name(client: FlaskClient, support: str) -> None:
|
||||||
|
element_id = _add_bouton(client, support)
|
||||||
|
client.post(
|
||||||
|
f"/document/{support}/elements/{element_id}/upload-attachment",
|
||||||
|
data={"file": (io.BytesIO(b"%PDF-1.4 fake pdf content"), "fiche-consignes.pdf")},
|
||||||
|
content_type="multipart/form-data",
|
||||||
|
)
|
||||||
|
resp = client.get(f"/document/{support}/elements/{element_id}/download-attachment")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.data == b"%PDF-1.4 fake pdf content"
|
||||||
|
assert "fiche-consignes.pdf" in resp.headers["Content-Disposition"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_download_attachment_404s_when_nothing_was_uploaded(client: FlaskClient, support: str) -> None:
|
||||||
|
element_id = _add_bouton(client, support)
|
||||||
|
resp = client.get(f"/document/{support}/elements/{element_id}/download-attachment")
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def _add_image(client: FlaskClient, support: str) -> int:
|
||||||
|
resp = client.post(f"/document/{support}/elements/add", data={"kind": "image", "page_id": _page_id(support)})
|
||||||
|
return int(resp.get_json()["id"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_image_sets_src_and_clears_svg_markup(client: FlaskClient, support: str) -> None:
|
||||||
|
element_id = _add_image(client, support)
|
||||||
|
document_engine.update_document_element_attributes(support, element_id, {"svg_markup": "<svg></svg>"})
|
||||||
|
resp = client.post(
|
||||||
|
f"/document/{support}/elements/{element_id}/upload-image",
|
||||||
|
data={"file": (io.BytesIO(b"fake png bytes"), "photo.png")},
|
||||||
|
content_type="multipart/form-data",
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
payload = resp.get_json()
|
||||||
|
assert payload["ok"] is True
|
||||||
|
assert payload["attributes"]["svg_markup"] == ""
|
||||||
|
src = payload["attributes"]["src"]
|
||||||
|
assert f"/document/{support}/uploads/" in src
|
||||||
|
assert f'src="{src}"' in payload["rendered_html"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_image_rejects_a_non_image_element(client: FlaskClient, support: str) -> None:
|
||||||
|
element_id = _add_bouton(client, support)
|
||||||
|
resp = client.post(
|
||||||
|
f"/document/{support}/elements/{element_id}/upload-image",
|
||||||
|
data={"file": (io.BytesIO(b"peu importe"), "x.png")},
|
||||||
|
content_type="multipart/form-data",
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_image_rejects_a_missing_file(client: FlaskClient, support: str) -> None:
|
||||||
|
element_id = _add_image(client, support)
|
||||||
|
resp = client.post(f"/document/{support}/elements/{element_id}/upload-image", data={})
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_image_rejects_a_disallowed_extension(client: FlaskClient, support: str) -> None:
|
||||||
|
element_id = _add_image(client, support)
|
||||||
|
resp = client.post(
|
||||||
|
f"/document/{support}/elements/{element_id}/upload-image",
|
||||||
|
data={"file": (io.BytesIO(b"#!/bin/sh"), "script.sh")},
|
||||||
|
content_type="multipart/form-data",
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_uploaded_file_serves_the_stored_image(client: FlaskClient, support: str) -> None:
|
||||||
|
element_id = _add_image(client, support)
|
||||||
|
resp = client.post(
|
||||||
|
f"/document/{support}/elements/{element_id}/upload-image",
|
||||||
|
data={"file": (io.BytesIO(b"fake png bytes"), "photo.png")},
|
||||||
|
content_type="multipart/form-data",
|
||||||
|
)
|
||||||
|
src = resp.get_json()["attributes"]["src"]
|
||||||
|
resp = client.get(src)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.data == b"fake png bytes"
|
||||||
|
|
||||||
|
|
||||||
|
def test_uploaded_file_404s_for_an_unknown_filename(client: FlaskClient, support: str) -> None:
|
||||||
|
resp = client.get(f"/document/{support}/uploads/inconnu.png")
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_document_edit_has_no_theme_link_by_default(client: FlaskClient, support: str) -> None:
|
||||||
|
resp = client.get(f"/document/{support}/edit")
|
||||||
|
assert "document/themes/securite-incendie.css" not in resp.get_data(as_text=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_document_theme_apply_rejects_an_unknown_theme(client: FlaskClient, support: str) -> None:
|
||||||
|
resp = client.post(
|
||||||
|
f"/document/{support}/theme/apply",
|
||||||
|
json={"theme_id": "n-importe-quoi", "mode": "keep_content"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_document_theme_apply_rejects_an_invalid_mode(client: FlaskClient, support: str) -> None:
|
||||||
|
resp = client.post(
|
||||||
|
f"/document/{support}/theme/apply",
|
||||||
|
json={"theme_id": "securite-incendie", "mode": "n-importe-quoi"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_document_theme_apply_keep_content_only_sets_the_theme(client: FlaskClient, support: str) -> None:
|
||||||
|
page_id = _page_id(support)
|
||||||
|
client.post(f"/document/{support}/elements/add", data={"kind": "titre", "page_id": page_id})
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
f"/document/{support}/theme/apply",
|
||||||
|
json={"theme_id": "securite-incendie", "mode": "keep_content"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert db.get_document_theme(support) == "securite-incendie"
|
||||||
|
assert len(document_engine.list_document_elements(support, page_id)) == 1
|
||||||
|
|
||||||
|
edit_resp = client.get(f"/document/{support}/edit")
|
||||||
|
assert "document/themes/securite-incendie.css" in edit_resp.get_data(as_text=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_document_theme_apply_template_content_replaces_everything(client: FlaskClient, support: str) -> None:
|
||||||
|
page_id = _page_id(support)
|
||||||
|
client.post(f"/document/{support}/elements/add", data={"kind": "titre", "page_id": page_id})
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
f"/document/{support}/theme/apply",
|
||||||
|
json={"theme_id": "securite-incendie", "mode": "template_content"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
pages = document_engine.list_document_pages(support)
|
||||||
|
theme = document_engine.get_document_theme_entry("securite-incendie")
|
||||||
|
assert theme is not None
|
||||||
|
assert len(pages) == len(theme["seed_pages"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_document_theme_apply_with_empty_theme_id_removes_the_theme(client: FlaskClient, support: str) -> None:
|
||||||
|
# Retour utilisateur du 26/09/2026 : la modale "Utiliser un modèle"
|
||||||
|
# propose une carte "Aucun modèle" pour "revenir à un document de
|
||||||
|
# base" — theme_id vide est le signal que la route interprète comme
|
||||||
|
# un retrait, jamais comme une erreur de validation.
|
||||||
|
page_id = _page_id(support)
|
||||||
|
client.post(f"/document/{support}/elements/add", data={"kind": "titre", "page_id": page_id})
|
||||||
|
client.post(
|
||||||
|
f"/document/{support}/theme/apply",
|
||||||
|
json={"theme_id": "securite-incendie", "mode": "keep_content"},
|
||||||
|
)
|
||||||
|
assert db.get_document_theme(support) == "securite-incendie"
|
||||||
|
|
||||||
|
resp = client.post(f"/document/{support}/theme/apply", json={"theme_id": ""})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.get_json() == {"ok": True, "theme_id": ""}
|
||||||
|
# Retire vraiment la ligne _meta (voir db/supports/remove_document_theme.py)
|
||||||
|
# — jamais une chaîne vide qui violerait le contrat documenté de
|
||||||
|
# get_document_theme ("None" = aucun thème jamais appliqué/retiré).
|
||||||
|
assert db.get_document_theme(support) is None
|
||||||
|
# Le contenu n'est JAMAIS touché en retirant le thème.
|
||||||
|
assert len(document_engine.list_document_elements(support, page_id)) == 1
|
||||||
|
|
||||||
|
edit_resp = client.get(f"/document/{support}/edit")
|
||||||
|
assert "document/themes/securite-incendie.css" not in edit_resp.get_data(as_text=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_document_theme_apply_with_empty_theme_id_ignores_mode(client: FlaskClient, support: str) -> None:
|
||||||
|
resp = client.post(f"/document/{support}/theme/apply", json={"theme_id": "", "mode": "n-importe-quoi"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert db.get_document_theme(support) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_document_theme_preview_renders_every_seed_page_with_navigation(client: FlaskClient, support: str) -> None:
|
||||||
|
resp = client.get(f"/document/{support}/theme/securite-incendie/preview")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
html = resp.get_data(as_text=True)
|
||||||
|
theme = document_engine.get_document_theme_entry("securite-incendie")
|
||||||
|
assert theme is not None
|
||||||
|
page_count = len(theme["seed_pages"])
|
||||||
|
assert html.count('class="docPage"') == page_count
|
||||||
|
# Une seule page visible au chargement (les autres portent `hidden`,
|
||||||
|
# navigation gérée côté client — voir templates/document/
|
||||||
|
# document_theme_preview.html).
|
||||||
|
assert html.count("hidden>") == page_count - 1
|
||||||
|
assert "previewNav" in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_document_theme_preview_404s_for_an_unknown_theme(client: FlaskClient, support: str) -> None:
|
||||||
|
resp = client.get(f"/document/{support}/theme/n-importe-quoi/preview")
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
"""Catalogue de thèmes visuels (document_engine/themes/) — conversion du
|
||||||
|
contenu-seed en éléments "à plat" pour l'aperçu (seed_blocks_to_elements),
|
||||||
|
et respect par CHAQUE thème du catalogue des règles structurelles du
|
||||||
|
moteur (ex. un mini-jeu seul sur sa page, voir routes/document/
|
||||||
|
document_element_add.py) — vérifié ici statiquement sur les données du
|
||||||
|
catalogue, sans passer par une route HTTP."""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import document_engine as doc_engine
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_document_theme_entry_returns_none_for_an_unknown_id() -> None:
|
||||||
|
assert doc_engine.get_document_theme_entry("n-importe-quoi") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_document_theme_entry_returns_the_matching_entry() -> None:
|
||||||
|
entry = doc_engine.get_document_theme_entry("securite-incendie")
|
||||||
|
assert entry is not None
|
||||||
|
assert entry["id"] == "securite-incendie"
|
||||||
|
assert entry["name"] == "Sécurité Incendie"
|
||||||
|
assert len(entry["seed_pages"]) > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_securite_incendie_title_page_is_vertically_centered() -> None:
|
||||||
|
"""Retour utilisateur du 24/09/2026 : la page de garde doit être
|
||||||
|
centrée verticalement, comme une page de titre — les pages suivantes
|
||||||
|
restent alignées en haut (comportement par défaut)."""
|
||||||
|
entry = doc_engine.get_document_theme_entry("securite-incendie")
|
||||||
|
assert entry is not None
|
||||||
|
assert entry["seed_pages"][0]["vertical_align"] == "center"
|
||||||
|
assert entry["seed_pages"][1].get("vertical_align", "top") == "top"
|
||||||
|
|
||||||
|
|
||||||
|
def test_securite_incendie_kicker_badge_uses_the_new_badge_properties() -> None:
|
||||||
|
"""Retour utilisateur du 24/09/2026 : l'étiquette "Module obligatoire"
|
||||||
|
doit avoir une icône, une largeur au contenu, un arrondi complet et
|
||||||
|
du gras/majuscules, comme dans la maquette d'origine."""
|
||||||
|
entry = doc_engine.get_document_theme_entry("securite-incendie")
|
||||||
|
assert entry is not None
|
||||||
|
kicker = entry["seed_pages"][0]["blocks"][0]
|
||||||
|
assert kicker["kind"] == "badge"
|
||||||
|
attrs = kicker["attributes"]
|
||||||
|
assert attrs["svg_markup"]
|
||||||
|
assert attrs["width"] == "fit-content"
|
||||||
|
assert attrs["border_radius"] == "999px"
|
||||||
|
assert attrs["bold"] is True
|
||||||
|
assert attrs["uppercase"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_securite_incendie_intro_paragraph_has_a_max_width() -> None:
|
||||||
|
"""Retour utilisateur du 24/09/2026 : le sous-titre de la page de
|
||||||
|
garde doit rester aussi étroit que dans la maquette, pas étiré sur
|
||||||
|
toute la largeur de la page."""
|
||||||
|
entry = doc_engine.get_document_theme_entry("securite-incendie")
|
||||||
|
assert entry is not None
|
||||||
|
intro_paragraph = entry["seed_pages"][0]["blocks"][2]
|
||||||
|
assert intro_paragraph["kind"] == "paragraphe"
|
||||||
|
assert intro_paragraph["attributes"]["max_width"] == "60ch"
|
||||||
|
|
||||||
|
|
||||||
|
def test_seed_blocks_to_elements_assigns_unique_synthetic_ids() -> None:
|
||||||
|
blocks: list[dict[str, Any]] = [
|
||||||
|
{"kind": "titre", "attributes": {"content": "Titre"}},
|
||||||
|
{
|
||||||
|
"kind": "row",
|
||||||
|
"attributes": {"gap": 10},
|
||||||
|
"children": [
|
||||||
|
{"kind": "paragraphe", "attributes": {"content": "A"}},
|
||||||
|
{"kind": "paragraphe", "attributes": {"content": "B"}},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
elements = doc_engine.seed_blocks_to_elements(blocks)
|
||||||
|
ids = [el["id"] for el in elements]
|
||||||
|
assert len(ids) == len(set(ids))
|
||||||
|
assert len(elements) == 4
|
||||||
|
|
||||||
|
row = next(el for el in elements if el["kind"] == "row")
|
||||||
|
children = [el for el in elements if el["parent_id"] == row["id"]]
|
||||||
|
assert len(children) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_seed_blocks_to_elements_merges_onto_default_attributes() -> None:
|
||||||
|
elements = doc_engine.seed_blocks_to_elements([{"kind": "titre", "attributes": {"content": "Contenu seul"}}])
|
||||||
|
assert elements[0]["attributes"]["content"] == "Contenu seul"
|
||||||
|
assert elements[0]["attributes"]["style"] == "titre1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_seed_blocks_to_elements_renders_without_error() -> None:
|
||||||
|
theme = doc_engine.get_document_theme_entry("securite-incendie")
|
||||||
|
assert theme is not None
|
||||||
|
elements = doc_engine.seed_blocks_to_elements(theme["seed_pages"][0]["blocks"])
|
||||||
|
html = doc_engine.render_document(elements)
|
||||||
|
assert "docText" in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_theme_seed_page_has_a_valid_vertical_align() -> None:
|
||||||
|
for theme in doc_engine.DOCUMENT_THEMES:
|
||||||
|
for seed_page in theme["seed_pages"]:
|
||||||
|
align = seed_page.get("vertical_align", "top")
|
||||||
|
assert align in doc_engine.VERTICAL_ALIGNS, f"thème {theme['id']!r} : vertical_align invalide {align!r}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_theme_seed_page_respects_the_minigame_exclusivity_rule() -> None:
|
||||||
|
"""Un mini-jeu doit toujours être SEUL sur sa page (règle appliquée
|
||||||
|
côté route pour un ajout manuel, voir routes/document/
|
||||||
|
document_element_add.py) — le contenu-seed d'un thème la respecte
|
||||||
|
dès sa conception puisque document_engine.replace_document_content
|
||||||
|
ne la revérifie pas elle-même (contenu fourni par le thème, pas par
|
||||||
|
l'utilisateur)."""
|
||||||
|
for theme in doc_engine.DOCUMENT_THEMES:
|
||||||
|
for seed_page in theme["seed_pages"]:
|
||||||
|
page_blocks = seed_page["blocks"]
|
||||||
|
minigame_blocks = [b for b in page_blocks if b["kind"] in doc_engine.MINIGAME_KINDS]
|
||||||
|
if minigame_blocks:
|
||||||
|
assert len(page_blocks) == 1, (
|
||||||
|
f"thème {theme['id']!r} : une page avec mini-jeu doit contenir uniquement ce mini-jeu"
|
||||||
|
)
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
"""Modèle de données du mini-jeu Memory
|
||||||
|
(document_engine/labels/memory_config.py) — sans Flask, teste directement
|
||||||
|
sanitize_memory_config, et le rendu du plateau."""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import document_engine as doc_engine
|
||||||
|
|
||||||
|
|
||||||
|
def test_sanitize_memory_config_on_missing_input_returns_full_defaults() -> None:
|
||||||
|
assert doc_engine.sanitize_memory_config(None) == {"theme_color": "#ff5f2e", "mode": "paire", "cards": []}
|
||||||
|
|
||||||
|
|
||||||
|
def test_sanitize_memory_config_keeps_a_valid_card() -> None:
|
||||||
|
raw = {
|
||||||
|
"theme_color": "#123456",
|
||||||
|
"mode": "single",
|
||||||
|
"cards": [{"recto": {"image": "", "text": "?"}, "verso": {"image": "cat.png", "text": "Chat"}}],
|
||||||
|
}
|
||||||
|
config = doc_engine.sanitize_memory_config(raw)
|
||||||
|
assert config["theme_color"] == "#123456"
|
||||||
|
assert config["mode"] == "single"
|
||||||
|
assert config["cards"] == [{"recto": {"image": "", "text": "?"}, "verso": {"image": "cat.png", "text": "Chat"}}]
|
||||||
|
|
||||||
|
|
||||||
|
def test_sanitize_memory_config_allows_a_fully_blank_recto() -> None:
|
||||||
|
raw = {"cards": [{"recto": {}, "verso": {"text": "Chat"}}]}
|
||||||
|
config = doc_engine.sanitize_memory_config(raw)
|
||||||
|
assert config["cards"] == [{"recto": {"image": "", "text": ""}, "verso": {"image": "", "text": "Chat"}}]
|
||||||
|
|
||||||
|
|
||||||
|
def test_sanitize_memory_config_drops_a_card_with_a_fully_blank_verso() -> None:
|
||||||
|
raw = {"cards": [{"recto": {"text": "Indice"}, "verso": {"image": "", "text": " "}}]}
|
||||||
|
assert doc_engine.sanitize_memory_config(raw)["cards"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_sanitize_memory_config_falls_back_to_paire_on_invalid_mode() -> None:
|
||||||
|
assert doc_engine.sanitize_memory_config({"mode": "n_importe_quoi"})["mode"] == "paire"
|
||||||
|
|
||||||
|
|
||||||
|
def test_sanitize_memory_config_caps_cards_at_max() -> None:
|
||||||
|
raw = {"cards": [{"verso": {"text": f"Carte {i}"}} for i in range(doc_engine.MAX_CARDS + 3)]}
|
||||||
|
config = doc_engine.sanitize_memory_config(raw)
|
||||||
|
assert len(config["cards"]) == doc_engine.MAX_CARDS
|
||||||
|
|
||||||
|
|
||||||
|
def test_sanitize_memory_config_ignores_garbage_top_level_input() -> None:
|
||||||
|
assert doc_engine.sanitize_memory_config("n'importe quoi") == doc_engine.DEFAULT_MEMORY_CONFIG
|
||||||
|
|
||||||
|
|
||||||
|
def _memory_element(attributes: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return {"id": 1, "kind": "memory", "parent_id": None, "order_index": 0, "attributes": attributes}
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_memory_without_cards_shows_only_the_badge() -> None:
|
||||||
|
html = doc_engine.render_document_element(_memory_element(doc_engine.DEFAULT_MEMORY_CONFIG), {})
|
||||||
|
assert "0 carte" in html
|
||||||
|
assert "docMemoryPlayer" not in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_memory_with_cards_includes_the_interactive_board() -> None:
|
||||||
|
config = doc_engine.sanitize_memory_config(
|
||||||
|
{"mode": "paire", "cards": [{"verso": {"text": "Chat"}}, {"verso": {"text": "Chien"}}]}
|
||||||
|
)
|
||||||
|
html = doc_engine.render_document_element(_memory_element(config), {})
|
||||||
|
assert "docMemoryPlayer" in html
|
||||||
|
assert "data-memory-config=" in html
|
||||||
|
assert "2 cartes" in html
|
||||||
|
assert "mode paire" in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_memory_duplicates_cards_in_paire_mode_only() -> None:
|
||||||
|
import json
|
||||||
|
|
||||||
|
config = doc_engine.sanitize_memory_config({"mode": "paire", "cards": [{"verso": {"text": "Chat"}}]})
|
||||||
|
html_paire = doc_engine.render_document_element(_memory_element(config), {})
|
||||||
|
start = html_paire.find('data-memory-config="') + len('data-memory-config="')
|
||||||
|
end = html_paire.find('"', start)
|
||||||
|
embedded = json.loads(html_paire[start:end].replace(""", '"'))
|
||||||
|
assert len(embedded["cards"]) == 2
|
||||||
|
assert embedded["cards"][0]["card_index"] == embedded["cards"][1]["card_index"] == 0
|
||||||
|
|
||||||
|
config_single = doc_engine.sanitize_memory_config({"mode": "single", "cards": [{"verso": {"text": "Chat"}}]})
|
||||||
|
html_single = doc_engine.render_document_element(_memory_element(config_single), {})
|
||||||
|
start2 = html_single.find('data-memory-config="') + len('data-memory-config="')
|
||||||
|
end2 = html_single.find('"', start2)
|
||||||
|
embedded_single = json.loads(html_single[start2:end2].replace(""", '"'))
|
||||||
|
assert len(embedded_single["cards"]) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_memory_escapes_card_text_in_embedded_json() -> None:
|
||||||
|
config = doc_engine.sanitize_memory_config({"cards": [{"verso": {"text": '"><script>alert(1)</script>'}}]})
|
||||||
|
html = doc_engine.render_document_element(_memory_element(config), {})
|
||||||
|
assert "<script>alert(1)</script>" not in html
|
||||||
|
assert "<script>" in html
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
"""Modèle de données du mini-jeu Mots mêlés
|
||||||
|
(document_engine/labels/mots_config.py) — sans Flask, teste directement
|
||||||
|
sanitize_mots_config, la construction de la grille
|
||||||
|
(_build_mots_grid, via render_document_element), et le rendu du plateau."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import document_engine as doc_engine
|
||||||
|
from document_engine.rendering.render_document_element import _build_mots_grid
|
||||||
|
|
||||||
|
|
||||||
|
def test_sanitize_mots_config_on_missing_input_returns_full_defaults() -> None:
|
||||||
|
assert doc_engine.sanitize_mots_config(None) == {"theme_color": "#ff5f2e", "words": []}
|
||||||
|
|
||||||
|
|
||||||
|
def test_sanitize_mots_config_keeps_valid_words_uppercased() -> None:
|
||||||
|
raw = {"theme_color": "#123456", "words": ["chat", "chien"]}
|
||||||
|
config = doc_engine.sanitize_mots_config(raw)
|
||||||
|
assert config["theme_color"] == "#123456"
|
||||||
|
assert config["words"] == ["CHAT", "CHIEN"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_sanitize_mots_config_strips_accents_and_non_letters() -> None:
|
||||||
|
raw = {"words": ["éléphant", "un chat !", "12"]}
|
||||||
|
config = doc_engine.sanitize_mots_config(raw)
|
||||||
|
assert config["words"] == ["ELEPHANT", "UNCHAT"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_sanitize_mots_config_drops_words_shorter_than_min_length() -> None:
|
||||||
|
raw = {"words": ["a", "ok"]}
|
||||||
|
assert doc_engine.sanitize_mots_config(raw)["words"] == ["OK"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_sanitize_mots_config_deduplicates_words() -> None:
|
||||||
|
raw = {"words": ["chat", "CHAT", "Chat"]}
|
||||||
|
assert doc_engine.sanitize_mots_config(raw)["words"] == ["CHAT"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_sanitize_mots_config_caps_words_at_max() -> None:
|
||||||
|
alphabet = "abcdefghijklmnop"
|
||||||
|
raw = {"words": [f"mot{alphabet[i]}" for i in range(doc_engine.MAX_WORDS + 3)]}
|
||||||
|
config = doc_engine.sanitize_mots_config(raw)
|
||||||
|
assert len(config["words"]) == doc_engine.MAX_WORDS
|
||||||
|
|
||||||
|
|
||||||
|
def test_sanitize_mots_config_ignores_garbage_top_level_input() -> None:
|
||||||
|
assert doc_engine.sanitize_mots_config("n'importe quoi") == doc_engine.DEFAULT_MOTS_CONFIG
|
||||||
|
|
||||||
|
|
||||||
|
def _mots_element(attributes: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return {"id": 1, "kind": "mots", "parent_id": None, "order_index": 0, "attributes": attributes}
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_mots_without_words_shows_only_the_badge() -> None:
|
||||||
|
html = doc_engine.render_document_element(_mots_element(doc_engine.DEFAULT_MOTS_CONFIG), {})
|
||||||
|
assert "0 mot" in html
|
||||||
|
assert "docMotsPlayer" not in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_mots_with_words_includes_the_interactive_grid() -> None:
|
||||||
|
config = doc_engine.sanitize_mots_config({"words": ["chat", "chien", "lapin", "oiseau", "poisson"]})
|
||||||
|
html = doc_engine.render_document_element(_mots_element(config), {})
|
||||||
|
assert "docMotsPlayer" in html
|
||||||
|
assert "data-mots-config=" in html
|
||||||
|
assert "5 mots" in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_mots_escapes_word_text_in_embedded_json() -> None:
|
||||||
|
# Un mot ne peut de toute façon contenir que des lettres A-Z une fois
|
||||||
|
# sanitizé (voir sanitize_mots_config) : rien à échapper dans le texte
|
||||||
|
# du mot lui-même. Le seul contenu utilisateur qui atteint réellement
|
||||||
|
# l'attribut embarqué est donc déjà sûr par construction — ce test
|
||||||
|
# vérifie que l'échappement JSON standard reste bien en place (guillemets).
|
||||||
|
config = doc_engine.sanitize_mots_config({"words": ["chat", "chien"]})
|
||||||
|
html = doc_engine.render_document_element(_mots_element(config), {})
|
||||||
|
start = html.find('data-mots-config="') + len('data-mots-config="')
|
||||||
|
end = html.find('"', start)
|
||||||
|
embedded = json.loads(html[start:end].replace(""", '"'))
|
||||||
|
assert {"CHAT", "CHIEN"} <= {w["text"] for w in embedded["words"]}
|
||||||
|
|
||||||
|
|
||||||
|
def _cells_spell(grid: list[list[str]], cells: list[list[int]]) -> str:
|
||||||
|
return "".join(grid[r][c] for r, c in cells)
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_mots_grid_places_every_word_along_its_own_cells() -> None:
|
||||||
|
words = ["CHAT", "CHIEN", "OISEAU", "POISSON", "LAPIN"]
|
||||||
|
built = _build_mots_grid(words)
|
||||||
|
size = built["size"]
|
||||||
|
grid = built["grid"]
|
||||||
|
assert len(grid) == size
|
||||||
|
assert all(len(row) == size for row in grid)
|
||||||
|
placed = {w["text"] for w in built["words"]}
|
||||||
|
assert placed == set(words)
|
||||||
|
for w in built["words"]:
|
||||||
|
assert _cells_spell(grid, w["cells"]) == w["text"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_mots_grid_only_uses_the_four_allowed_directions() -> None:
|
||||||
|
words = ["CHAT", "CHIEN", "OISEAU", "POISSON", "LAPIN", "SOURIS"]
|
||||||
|
built = _build_mots_grid(words)
|
||||||
|
allowed = {(0, 1), (1, 0), (1, 1), (1, -1)}
|
||||||
|
for w in built["words"]:
|
||||||
|
cells = w["cells"]
|
||||||
|
if len(cells) < 2:
|
||||||
|
continue
|
||||||
|
delta_row = cells[1][0] - cells[0][0]
|
||||||
|
delta_col = cells[1][1] - cells[0][1]
|
||||||
|
assert (delta_row, delta_col) in allowed
|
||||||
|
for i in range(len(cells) - 1):
|
||||||
|
assert cells[i + 1][0] - cells[i][0] == delta_row
|
||||||
|
assert cells[i + 1][1] - cells[i][1] == delta_col
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_mots_grid_fills_every_remaining_cell_with_a_letter() -> None:
|
||||||
|
built = _build_mots_grid(["CHAT", "CHIEN"])
|
||||||
|
assert all(cell for row in built["grid"] for cell in row)
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_mots_grid_on_empty_words_returns_an_empty_grid() -> None:
|
||||||
|
assert _build_mots_grid([]) == {"size": 0, "grid": [], "words": []}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
"""Nettoyage du SVG inline collé comme contenu d'image (voir
|
||||||
|
document_engine/rendering/sanitize_svg_markup.py) — chaque cas ici
|
||||||
|
reproduit une charge malveillante RÉELLE plutôt qu'une simple assertion
|
||||||
|
"pas de régression" (voir CLAUDE.md, exigence pour tout changement de
|
||||||
|
comportement lié à l'échappement/la sécurité)."""
|
||||||
|
|
||||||
|
from document_engine.rendering.sanitize_svg_markup import sanitize_svg_markup
|
||||||
|
|
||||||
|
|
||||||
|
def test_strips_script_tag_and_its_content() -> None:
|
||||||
|
result = sanitize_svg_markup("<svg><script>alert(document.cookie)</script></svg>")
|
||||||
|
assert "<script>" not in result
|
||||||
|
assert "alert(document.cookie)" not in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_strips_event_handler_attributes() -> None:
|
||||||
|
result = sanitize_svg_markup('<svg onload="alert(1)"><circle onclick="alert(2)" cx="5" cy="5" r="3"/></svg>')
|
||||||
|
assert "onload" not in result
|
||||||
|
assert "onclick" not in result
|
||||||
|
assert "alert(" not in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_strips_href_to_block_javascript_uri() -> None:
|
||||||
|
result = sanitize_svg_markup('<svg><a href="javascript:alert(1)"><circle cx="1" cy="1" r="1"/></a></svg>')
|
||||||
|
assert "javascript:" not in result
|
||||||
|
assert "<a" not in result
|
||||||
|
assert "href" not in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_strips_foreignobject_and_embedded_html() -> None:
|
||||||
|
result = sanitize_svg_markup(
|
||||||
|
'<svg><foreignObject><body onload="alert(1)"><img src="x" onerror="alert(2)"></body></foreignObject></svg>'
|
||||||
|
)
|
||||||
|
assert "foreignObject".lower() not in result.lower()
|
||||||
|
assert "onerror" not in result
|
||||||
|
assert "alert(" not in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_strips_style_attribute_and_style_tag() -> None:
|
||||||
|
result = sanitize_svg_markup(
|
||||||
|
'<svg><style>*{display:none}</style><circle style="fill:red" cx="1" cy="1" r="1"/></svg>'
|
||||||
|
)
|
||||||
|
assert "<style>" not in result
|
||||||
|
assert "style=" not in result
|
||||||
|
assert "display:none" not in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_strips_use_tag_referencing_external_content() -> None:
|
||||||
|
result = sanitize_svg_markup('<svg><use href="https://evil.test/x.svg#payload"/></svg>')
|
||||||
|
assert "<use" not in result
|
||||||
|
assert "evil.test" not in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_keeps_allowed_shape_and_presentation_attributes() -> None:
|
||||||
|
result = sanitize_svg_markup(
|
||||||
|
'<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" fill="#ff0000" stroke="#000"/></svg>'
|
||||||
|
)
|
||||||
|
assert "<svg" in result
|
||||||
|
assert "<circle" in result
|
||||||
|
assert 'cx="12"' in result
|
||||||
|
assert 'fill="#ff0000"' in result
|
||||||
|
assert 'stroke="#000"' in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_self_closing_disallowed_tag_does_not_swallow_following_content() -> None:
|
||||||
|
result = sanitize_svg_markup('<svg><script/><circle cx="1" cy="1" r="1"/></svg>')
|
||||||
|
assert "<circle" in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_markup_returns_empty_string() -> None:
|
||||||
|
assert sanitize_svg_markup("") == ""
|
||||||
@@ -0,0 +1,310 @@
|
|||||||
|
"""Modèle de données du mini-jeu Scénario
|
||||||
|
(document_engine/labels/scenario_config.py) — sans Flask, teste
|
||||||
|
directement sanitize_scenario_config (arbre de décision, graphe visuel
|
||||||
|
avec positions x/y), et le rendu du plateau."""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import document_engine as doc_engine
|
||||||
|
|
||||||
|
|
||||||
|
def test_sanitize_scenario_config_on_missing_input_returns_full_defaults() -> None:
|
||||||
|
assert doc_engine.sanitize_scenario_config(None) == {"theme_color": "#ff5f2e", "scenarios": []}
|
||||||
|
|
||||||
|
|
||||||
|
def test_sanitize_scenario_config_keeps_a_valid_tree_as_is() -> None:
|
||||||
|
raw = {
|
||||||
|
"theme_color": "#123456",
|
||||||
|
"scenarios": [
|
||||||
|
{
|
||||||
|
"title": "Mot de passe",
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": "n1",
|
||||||
|
"text": "Un collègue vous demande son mot de passe.",
|
||||||
|
"x": 40,
|
||||||
|
"y": 60,
|
||||||
|
"choices": [
|
||||||
|
{"text": "Le lui donner", "target_id": "n2"},
|
||||||
|
{"text": "Refuser", "target_id": None},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{"id": "n2", "text": "Violation de sécurité.", "x": 300, "y": 60, "choices": []},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
config = doc_engine.sanitize_scenario_config(raw)
|
||||||
|
assert config["theme_color"] == "#123456"
|
||||||
|
assert config["scenarios"] == raw["scenarios"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_sanitize_scenario_config_keeps_explicit_xy_position() -> None:
|
||||||
|
raw = {
|
||||||
|
"scenarios": [
|
||||||
|
{"title": "Titre", "nodes": [{"id": "n1", "text": "Situation", "x": 500, "y": 300, "choices": []}]}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
config = doc_engine.sanitize_scenario_config(raw)
|
||||||
|
node = config["scenarios"][0]["nodes"][0]
|
||||||
|
assert (node["x"], node["y"]) == (500, 300)
|
||||||
|
|
||||||
|
|
||||||
|
def test_sanitize_scenario_config_falls_back_to_a_cascading_grid_when_xy_is_missing() -> None:
|
||||||
|
"""Jamais (0, 0) pour tous les nœuds (qui les empilerait exactement au
|
||||||
|
même endroit sur le graphe visuel) : une position par défaut dérivée
|
||||||
|
de l'INDEX du nœud dans la liste."""
|
||||||
|
raw = {
|
||||||
|
"scenarios": [
|
||||||
|
{
|
||||||
|
"title": "Titre",
|
||||||
|
"nodes": [
|
||||||
|
{"id": "n1", "text": "A", "choices": []},
|
||||||
|
{"id": "n2", "text": "B", "choices": []},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
config = doc_engine.sanitize_scenario_config(raw)
|
||||||
|
nodes = config["scenarios"][0]["nodes"]
|
||||||
|
assert (nodes[0]["x"], nodes[0]["y"]) != (nodes[1]["x"], nodes[1]["y"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_sanitize_scenario_config_falls_back_to_grid_when_xy_is_not_a_number() -> None:
|
||||||
|
raw = {
|
||||||
|
"scenarios": [
|
||||||
|
{"title": "Titre", "nodes": [{"id": "n1", "text": "Situation", "x": "beaucoup", "y": None, "choices": []}]}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
config = doc_engine.sanitize_scenario_config(raw)
|
||||||
|
node = config["scenarios"][0]["nodes"][0]
|
||||||
|
assert isinstance(node["x"], (int, float))
|
||||||
|
assert isinstance(node["y"], (int, float))
|
||||||
|
|
||||||
|
|
||||||
|
def test_sanitize_scenario_config_rejects_a_boolean_as_xy() -> None:
|
||||||
|
"""True/False sont des int en Python (bool hérite de int) — même
|
||||||
|
garde-fou que pour correct_index dans l'ancien modèle : un JSON
|
||||||
|
malformé pourrait glisser `true` là où une coordonnée est attendue."""
|
||||||
|
raw = {
|
||||||
|
"scenarios": [
|
||||||
|
{"title": "Titre", "nodes": [{"id": "n1", "text": "Situation", "x": True, "y": False, "choices": []}]}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
config = doc_engine.sanitize_scenario_config(raw)
|
||||||
|
node = config["scenarios"][0]["nodes"][0]
|
||||||
|
assert node["x"] is not True
|
||||||
|
assert node["y"] is not False
|
||||||
|
|
||||||
|
|
||||||
|
def test_sanitize_scenario_config_drops_a_scenario_with_empty_title() -> None:
|
||||||
|
raw = {"scenarios": [{"title": " ", "nodes": [{"id": "n1", "text": "Situation", "choices": []}]}]}
|
||||||
|
assert doc_engine.sanitize_scenario_config(raw)["scenarios"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_sanitize_scenario_config_drops_a_scenario_with_no_valid_node() -> None:
|
||||||
|
raw = {"scenarios": [{"title": "Titre", "nodes": [{"id": "n1", "text": " ", "choices": []}]}]}
|
||||||
|
assert doc_engine.sanitize_scenario_config(raw)["scenarios"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_sanitize_scenario_config_drops_a_node_missing_its_id() -> None:
|
||||||
|
raw = {
|
||||||
|
"scenarios": [
|
||||||
|
{
|
||||||
|
"title": "Titre",
|
||||||
|
"nodes": [
|
||||||
|
{"id": "n1", "text": "Situation", "choices": []},
|
||||||
|
{"text": "Nœud sans id", "choices": []},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
config = doc_engine.sanitize_scenario_config(raw)
|
||||||
|
assert len(config["scenarios"][0]["nodes"]) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_sanitize_scenario_config_drops_a_node_with_empty_text() -> None:
|
||||||
|
raw = {
|
||||||
|
"scenarios": [
|
||||||
|
{
|
||||||
|
"title": "Titre",
|
||||||
|
"nodes": [
|
||||||
|
{"id": "n1", "text": "Situation", "choices": []},
|
||||||
|
{"id": "n2", "text": " ", "choices": []},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
config = doc_engine.sanitize_scenario_config(raw)
|
||||||
|
assert [n["id"] for n in config["scenarios"][0]["nodes"]] == ["n1"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_sanitize_scenario_config_drops_duplicate_node_ids() -> None:
|
||||||
|
raw = {
|
||||||
|
"scenarios": [
|
||||||
|
{
|
||||||
|
"title": "Titre",
|
||||||
|
"nodes": [
|
||||||
|
{"id": "n1", "text": "Situation", "choices": []},
|
||||||
|
{"id": "n1", "text": "Doublon", "choices": []},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
config = doc_engine.sanitize_scenario_config(raw)
|
||||||
|
assert len(config["scenarios"][0]["nodes"]) == 1
|
||||||
|
assert config["scenarios"][0]["nodes"][0]["text"] == "Situation"
|
||||||
|
|
||||||
|
|
||||||
|
def test_sanitize_scenario_config_drops_a_choice_with_empty_text() -> None:
|
||||||
|
raw = {
|
||||||
|
"scenarios": [
|
||||||
|
{
|
||||||
|
"title": "Titre",
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": "n1",
|
||||||
|
"text": "Situation",
|
||||||
|
"choices": [{"text": " ", "target_id": None}, {"text": "Valide", "target_id": None}],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
config = doc_engine.sanitize_scenario_config(raw)
|
||||||
|
assert [c["text"] for c in config["scenarios"][0]["nodes"][0]["choices"]] == ["Valide"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_sanitize_scenario_config_caps_choices_per_node_at_max() -> None:
|
||||||
|
raw = {
|
||||||
|
"scenarios": [
|
||||||
|
{
|
||||||
|
"title": "Titre",
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": "n1",
|
||||||
|
"text": "Situation",
|
||||||
|
"choices": [
|
||||||
|
{"text": f"Choix {i}", "target_id": None}
|
||||||
|
for i in range(doc_engine.MAX_SCENARIO_CHOICES + 3)
|
||||||
|
],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
config = doc_engine.sanitize_scenario_config(raw)
|
||||||
|
assert len(config["scenarios"][0]["nodes"][0]["choices"]) == doc_engine.MAX_SCENARIO_CHOICES
|
||||||
|
|
||||||
|
|
||||||
|
def test_sanitize_scenario_config_allows_a_node_with_zero_choices() -> None:
|
||||||
|
"""Un nœud sans choix est une fin de branche VALIDE — contrairement à
|
||||||
|
l'ancien modèle (2 à 4 choix obligatoires), il n'y a plus de notion
|
||||||
|
de bonne/mauvaise réponse à valider ici."""
|
||||||
|
raw = {"scenarios": [{"title": "Titre", "nodes": [{"id": "n1", "text": "Fin.", "choices": []}]}]}
|
||||||
|
config = doc_engine.sanitize_scenario_config(raw)
|
||||||
|
assert config["scenarios"][0]["nodes"][0]["choices"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_sanitize_scenario_config_nulls_a_target_id_pointing_to_a_missing_node() -> None:
|
||||||
|
raw = {
|
||||||
|
"scenarios": [
|
||||||
|
{
|
||||||
|
"title": "Titre",
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": "n1",
|
||||||
|
"text": "Situation",
|
||||||
|
"choices": [{"text": "Vers un nœud supprimé", "target_id": "n99"}],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
config = doc_engine.sanitize_scenario_config(raw)
|
||||||
|
assert config["scenarios"][0]["nodes"][0]["choices"][0]["target_id"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_sanitize_scenario_config_keeps_a_target_id_pointing_to_a_later_node() -> None:
|
||||||
|
"""L'ordre des nœuds dans la liste ne contraint pas les références —
|
||||||
|
un choix du nœud 0 peut viser un nœud défini plus loin dans la liste
|
||||||
|
(voir le second passage de _sanitize_scenario, après avoir construit
|
||||||
|
l'ensemble complet des ids valides)."""
|
||||||
|
raw = {
|
||||||
|
"scenarios": [
|
||||||
|
{
|
||||||
|
"title": "Titre",
|
||||||
|
"nodes": [
|
||||||
|
{"id": "n1", "text": "Situation", "choices": [{"text": "Continuer", "target_id": "n2"}]},
|
||||||
|
{"id": "n2", "text": "Suite", "choices": []},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
config = doc_engine.sanitize_scenario_config(raw)
|
||||||
|
assert config["scenarios"][0]["nodes"][0]["choices"][0]["target_id"] == "n2"
|
||||||
|
|
||||||
|
|
||||||
|
def test_sanitize_scenario_config_keeps_multiple_scenarios_in_order() -> None:
|
||||||
|
raw = {
|
||||||
|
"scenarios": [
|
||||||
|
{"title": "Scénario 1", "nodes": [{"id": "n1", "text": "Situation 1", "choices": []}]},
|
||||||
|
{"title": "Scénario 2", "nodes": [{"id": "n1", "text": "Situation 2", "choices": []}]},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
config = doc_engine.sanitize_scenario_config(raw)
|
||||||
|
assert [s["title"] for s in config["scenarios"]] == ["Scénario 1", "Scénario 2"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_sanitize_scenario_config_ignores_garbage_top_level_input() -> None:
|
||||||
|
assert doc_engine.sanitize_scenario_config("n'importe quoi") == doc_engine.DEFAULT_SCENARIO_CONFIG
|
||||||
|
|
||||||
|
|
||||||
|
def _scenario_element(attributes: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return {"id": 1, "kind": "scenario", "parent_id": None, "order_index": 0, "attributes": attributes}
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_scenario_without_scenarios_shows_only_the_badge() -> None:
|
||||||
|
html = doc_engine.render_document_element(_scenario_element(doc_engine.DEFAULT_SCENARIO_CONFIG), {})
|
||||||
|
assert "0 scénario" in html
|
||||||
|
assert "docScenarioPlayer" not in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_scenario_with_scenarios_includes_the_interactive_player() -> None:
|
||||||
|
config = doc_engine.sanitize_scenario_config(
|
||||||
|
{
|
||||||
|
"scenarios": [
|
||||||
|
{
|
||||||
|
"title": "Mot de passe",
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": "n1",
|
||||||
|
"text": "Un collègue vous demande son mot de passe.",
|
||||||
|
"choices": [{"text": "Refuser", "target_id": None}],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
html = doc_engine.render_document_element(_scenario_element(config), {})
|
||||||
|
assert "docScenarioPlayer" in html
|
||||||
|
assert "docQuizOptions" in html
|
||||||
|
assert "data-scenario-config=" in html
|
||||||
|
assert "1 scénario" in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_scenario_escapes_node_text_in_embedded_json() -> None:
|
||||||
|
config = doc_engine.sanitize_scenario_config(
|
||||||
|
{
|
||||||
|
"scenarios": [
|
||||||
|
{
|
||||||
|
"title": "Titre",
|
||||||
|
"nodes": [{"id": "n1", "text": '"><script>alert(1)</script>', "choices": []}],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
html = doc_engine.render_document_element(_scenario_element(config), {})
|
||||||
|
assert "<script>alert(1)</script>" not in html
|
||||||
|
assert "<script>" in html
|
||||||
@@ -16,6 +16,16 @@ def test_create_support_creates_its_own_db_file_and_schema(tmp_support_slug_clea
|
|||||||
assert meta["name"] == "Sécurité incendie"
|
assert meta["name"] == "Sécurité incendie"
|
||||||
|
|
||||||
|
|
||||||
|
def test_new_support_has_no_page_by_default(tmp_support_slug_cleanup: Any) -> None:
|
||||||
|
# Retour utilisateur du 26/09/2026 : "l'éditeur ne dois plus etre
|
||||||
|
# obliger d'avoir une page active" — voir document_engine/pages/pages.md.
|
||||||
|
import document_engine
|
||||||
|
|
||||||
|
slug = db.create_support("Nouveau projet", owner_folder="52")
|
||||||
|
tmp_support_slug_cleanup(slug)
|
||||||
|
assert document_engine.list_document_pages(slug) == []
|
||||||
|
|
||||||
|
|
||||||
def test_list_supports_scopes_to_owner_and_excludes_games(
|
def test_list_supports_scopes_to_owner_and_excludes_games(
|
||||||
tmp_support_slug_cleanup: Any, tmp_game_slug_cleanup: Any
|
tmp_support_slug_cleanup: Any, tmp_game_slug_cleanup: Any
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -59,3 +69,48 @@ def test_delete_support_removes_it_from_the_listing(tmp_support_slug_cleanup: An
|
|||||||
assert [s["slug"] for s in db.list_supports("48")] == [slug]
|
assert [s["slug"] for s in db.list_supports("48")] == [slug]
|
||||||
db.delete_support(slug)
|
db.delete_support(slug)
|
||||||
assert db.list_supports("48") == []
|
assert db.list_supports("48") == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_new_support_has_no_theme_by_default(tmp_support_slug_cleanup: Any) -> None:
|
||||||
|
slug = db.create_support("Sans thème", owner_folder="49")
|
||||||
|
tmp_support_slug_cleanup(slug)
|
||||||
|
assert db.get_document_theme(slug) is None
|
||||||
|
assert db.support_meta(slug)["theme"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_document_theme_persists_and_is_readable_back(tmp_support_slug_cleanup: Any) -> None:
|
||||||
|
slug = db.create_support("Avec thème", owner_folder="50")
|
||||||
|
tmp_support_slug_cleanup(slug)
|
||||||
|
db.set_document_theme(slug, "securite-incendie")
|
||||||
|
assert db.get_document_theme(slug) == "securite-incendie"
|
||||||
|
assert db.support_meta(slug)["theme"] == "securite-incendie"
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_document_theme_can_be_changed(tmp_support_slug_cleanup: Any) -> None:
|
||||||
|
slug = db.create_support("Change de thème", owner_folder="51")
|
||||||
|
tmp_support_slug_cleanup(slug)
|
||||||
|
db.set_document_theme(slug, "securite-incendie")
|
||||||
|
db.set_document_theme(slug, "autre-theme")
|
||||||
|
assert db.get_document_theme(slug) == "autre-theme"
|
||||||
|
|
||||||
|
|
||||||
|
def test_remove_document_theme_resets_to_none(tmp_support_slug_cleanup: Any) -> None:
|
||||||
|
# Retour utilisateur du 26/09/2026 : la modale "Utiliser un modèle"
|
||||||
|
# propose une carte "Aucun modèle" pour "revenir à un document de
|
||||||
|
# base" — get_document_theme doit redevenir None, jamais une chaîne
|
||||||
|
# vide (contrat documenté dans db/supports/get_document_theme.py).
|
||||||
|
slug = db.create_support("Retire son thème", owner_folder="54")
|
||||||
|
tmp_support_slug_cleanup(slug)
|
||||||
|
db.set_document_theme(slug, "securite-incendie")
|
||||||
|
assert db.get_document_theme(slug) == "securite-incendie"
|
||||||
|
|
||||||
|
db.remove_document_theme(slug)
|
||||||
|
assert db.get_document_theme(slug) is None
|
||||||
|
assert db.support_meta(slug)["theme"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_remove_document_theme_is_a_noop_when_none_was_ever_set(tmp_support_slug_cleanup: Any) -> None:
|
||||||
|
slug = db.create_support("Jamais de thème", owner_folder="55")
|
||||||
|
tmp_support_slug_cleanup(slug)
|
||||||
|
db.remove_document_theme(slug)
|
||||||
|
assert db.get_document_theme(slug) is None
|
||||||
|
|||||||
@@ -26,3 +26,15 @@ from core import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
_ = (auth_guard, csrf, csrf_guard, db_teardown_guard, jinja_filters, recovery_codes_flash)
|
_ = (auth_guard, csrf, csrf_guard, db_teardown_guard, jinja_filters, recovery_codes_flash)
|
||||||
|
|
||||||
|
# routes/document/document_theme_preview.py::document_theme_preview(slug, theme_id) —
|
||||||
|
# `slug` doit rester dans la signature (Flask appelle la vue avec un
|
||||||
|
# kwarg par segment <slug>/<theme_id> de la route, TypeError sinon), mais
|
||||||
|
# le corps de la fonction ne s'en sert pas : l'aperçu d'un thème ne
|
||||||
|
# dépend d'aucune donnée DU support, `slug` ne sert qu'à laisser
|
||||||
|
# core/auth_guard.py (générique sur `request.view_args.get("slug")`)
|
||||||
|
# vérifier la propriété avant d'atteindre la vue.
|
||||||
|
|
||||||
|
|
||||||
|
def _unused_but_required_route_param(slug: str) -> None:
|
||||||
|
_ = slug
|
||||||
|
|||||||
Reference in New Issue
Block a user