Compare commits
38
Commits
| 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 |
+1
-1
@@ -152,7 +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: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 |
|
||||
| `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 (monter/descendre/renommer/supprimer, voir `.docPageRowActions`), 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 |
|
||||
| `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"
|
||||
|
||||
|
||||
@@ -78,7 +78,11 @@ from .scoring.set_status import set_status
|
||||
from .slugify import slugify
|
||||
from .supports.create_support import create_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.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 .table_name_for import table_name_for
|
||||
|
||||
@@ -115,8 +119,12 @@ __all__ = [
|
||||
"set_scorm_version",
|
||||
"create_support",
|
||||
"list_supports",
|
||||
"support_dir",
|
||||
"support_meta",
|
||||
"delete_support",
|
||||
"get_document_theme",
|
||||
"remove_document_theme",
|
||||
"set_document_theme",
|
||||
"ONBOARDING_TYPES",
|
||||
"DEFAULT_ONBOARDING_TYPE",
|
||||
"get_onboarding_type",
|
||||
|
||||
@@ -7,7 +7,10 @@ indépendance."""
|
||||
from .create_support import create_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 .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_dir import support_dir
|
||||
from .support_meta import support_meta
|
||||
@@ -20,8 +23,11 @@ __all__ = [
|
||||
"create_support",
|
||||
"delete_support",
|
||||
"ensure_document_pages_schema",
|
||||
"get_document_theme",
|
||||
"install_support_teardown_safety_net",
|
||||
"list_supports",
|
||||
"remove_document_theme",
|
||||
"set_document_theme",
|
||||
"split_slug",
|
||||
"support_dir",
|
||||
"support_meta",
|
||||
|
||||
@@ -13,12 +13,15 @@ def create_support(name: str, owner_folder: str) -> str:
|
||||
mais un schéma entièrement différent (voir document_engine/ : un
|
||||
support est composé de PAGES — _document_pages —, chacune portant son
|
||||
propre flux d'éléments via _document_elements.page_id, jamais
|
||||
d'écrans/objets de scène comme côté jeu). Une première page ("Page 1")
|
||||
est toujours créée ici : un support n'existe jamais sans au moins une
|
||||
page (voir document_engine/pages/delete_document_page.py, qui refuse
|
||||
de supprimer la dernière). owner_folder n'est jamais optionnel ici
|
||||
(contrairement à create_game) : un support n'existe pas sans compte
|
||||
propriétaire."""
|
||||
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."""
|
||||
project_part = slugify(name)
|
||||
base = project_part
|
||||
n = 2
|
||||
@@ -39,6 +42,7 @@ def create_support(name: str, owner_folder: str) -> str:
|
||||
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
|
||||
);
|
||||
|
||||
@@ -54,7 +58,6 @@ def create_support(name: str, owner_folder: str) -> str:
|
||||
"""
|
||||
)
|
||||
conn.execute("INSERT INTO _meta (key, value) VALUES ('name', ?)", (name,))
|
||||
conn.execute("INSERT INTO _document_pages (title, order_index) VALUES ('Page 1', 0)")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return slug
|
||||
|
||||
@@ -8,7 +8,15 @@ def ensure_document_pages_schema(slug: str) -> None:
|
||||
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)."""
|
||||
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(
|
||||
"""
|
||||
@@ -20,13 +28,26 @@ def ensure_document_pages_schema(slug: str) -> None:
|
||||
);
|
||||
"""
|
||||
)
|
||||
page_count = conn.execute("SELECT COUNT(*) AS n FROM _document_pages").fetchone()["n"]
|
||||
if page_count == 0:
|
||||
conn.execute("INSERT INTO _document_pages (title, order_index) VALUES ('Page 1', 0)")
|
||||
|
||||
# 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:
|
||||
first_page_id = conn.execute("SELECT id FROM _document_pages ORDER BY order_index LIMIT 1").fetchone()["id"]
|
||||
# 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é
|
||||
|
||||
@@ -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 .get_document_theme import get_document_theme
|
||||
from .support_connection import connect_support
|
||||
|
||||
|
||||
@@ -10,4 +11,5 @@ def support_meta(slug: str) -> dict[str, Any]:
|
||||
return {
|
||||
"slug": 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.
|
||||
@@ -27,6 +27,7 @@ from .elements.delete_document_element import delete_document_element
|
||||
from .elements.get_document_element import get_document_element
|
||||
from .elements.list_document_elements import list_document_elements
|
||||
from .elements.move_document_element import move_document_element
|
||||
from .elements.move_document_element_to_page import move_document_element_to_page
|
||||
from .elements.update_document_element_attributes import update_document_element_attributes
|
||||
from .labels.association_config import (
|
||||
DEFAULT_ASSOCIATION_CONFIG,
|
||||
@@ -70,12 +71,18 @@ from .labels.scenario_config import (
|
||||
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.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__ = [
|
||||
"CARD_MODES",
|
||||
@@ -85,6 +92,7 @@ __all__ = [
|
||||
"DEFAULT_MOTS_CONFIG",
|
||||
"DEFAULT_QUIZ_CONFIG",
|
||||
"DEFAULT_SCENARIO_CONFIG",
|
||||
"DOCUMENT_THEMES",
|
||||
"ELEMENT_KIND_LABELS",
|
||||
"ELEMENT_LIBRARY",
|
||||
"MAX_CARDS",
|
||||
@@ -99,26 +107,34 @@ __all__ = [
|
||||
"MIN_TIMER_SECONDS",
|
||||
"MIN_WORDS",
|
||||
"MINIGAME_KINDS",
|
||||
"VERTICAL_ALIGNS",
|
||||
"add_document_element",
|
||||
"add_document_page",
|
||||
"delete_all_document_pages",
|
||||
"delete_document_element",
|
||||
"delete_document_page",
|
||||
"element_default_attributes",
|
||||
"get_document_element",
|
||||
"get_document_page",
|
||||
"get_document_theme_entry",
|
||||
"list_document_elements",
|
||||
"list_document_pages",
|
||||
"move_document_element",
|
||||
"move_document_element_to_page",
|
||||
"move_document_page",
|
||||
"quiz_total_points",
|
||||
"render_document",
|
||||
"render_document_element",
|
||||
"replace_document_content",
|
||||
"sanitize_association_config",
|
||||
"sanitize_element_attributes",
|
||||
"sanitize_memory_config",
|
||||
"sanitize_mots_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_page",
|
||||
]
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
# document_engine/
|
||||
|
||||
Moteur du support de formation — entité racine séparée du jeu 2D (voir
|
||||
`docs/plan/PLAN.md`). Package composé de trois sous-dossiers, chacun
|
||||
`docs/plan/PLAN.md`). Package composé de cinq sous-dossiers, chacun
|
||||
documenté séparément :
|
||||
|
||||
- [`elements/`](elements/elements.md) — CRUD des éléments du document
|
||||
(`_document_elements`).
|
||||
- [`labels/`](labels/labels.md) — catalogue statique des types d'éléments
|
||||
(bibliothèque, libellés, attributs par défaut).
|
||||
- [`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
|
||||
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
|
||||
du paquet (voir son `__all__`), pattern identique à `game_engine/__init__.py`.
|
||||
|
||||
@@ -57,6 +57,24 @@ affichée).
|
||||
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.
|
||||
- **Exceptions** : aucune levée explicitement ; un `element_id`
|
||||
inexistant ne fait rien (retour silencieux après vérification de son
|
||||
existence).
|
||||
|
||||
## `delete_document_element(slug: str, element_id: int) -> None`
|
||||
Supprime un élément. La contrainte `FOREIGN KEY ... ON DELETE CASCADE`
|
||||
(voir `db/supports/create_support.py`) retire automatiquement ses
|
||||
|
||||
@@ -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()
|
||||
@@ -5,13 +5,14 @@ défaut posés à la création de chaque type."""
|
||||
|
||||
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 .scenario_config import DEFAULT_SCENARIO_CONFIG
|
||||
|
||||
CONTENT_KINDS = ("titre", "paragraphe", "image", "bouton")
|
||||
CONTENT_KINDS = ("titre", "paragraphe", "image", "bouton", "liste_puces", "liste_numerotee", "badge", "carte")
|
||||
MINIGAME_KINDS = ("quiz", "association", "memory", "mots", "scenario", "zones")
|
||||
|
||||
# "row" n'apparaît jamais dans la bibliothèque (créé implicitement par le
|
||||
@@ -28,6 +29,10 @@ ELEMENT_KIND_LABELS: dict[str, str] = {
|
||||
"paragraphe": "Paragraphe",
|
||||
"image": "Image",
|
||||
"bouton": "Bouton",
|
||||
"liste_puces": "Liste à puces",
|
||||
"liste_numerotee": "Liste numérotée",
|
||||
"badge": "Étiquette",
|
||||
"carte": "Carte",
|
||||
"quiz": "Quiz",
|
||||
"association": "Association",
|
||||
"memory": "Memory",
|
||||
@@ -40,8 +45,24 @@ _TEXT_DEFAULTS = {
|
||||
"bold": False,
|
||||
"italic": False,
|
||||
"underline": False,
|
||||
"strikethrough": False,
|
||||
"align": "left",
|
||||
"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,
|
||||
}
|
||||
|
||||
|
||||
@@ -50,13 +71,158 @@ def element_default_attributes(kind: str) -> dict[str, Any]:
|
||||
docs/plan/PLAN.md §3.3/§3.4/§3.5 pour la liste des propriétés
|
||||
éditables par panneau, ici juste leur valeur de départ."""
|
||||
if kind == "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":
|
||||
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":
|
||||
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":
|
||||
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":
|
||||
return {"gap": 16, "align": "stretch", "justify": "flex-start"}
|
||||
if kind == "quiz":
|
||||
|
||||
@@ -5,8 +5,24 @@ groupée par catégorie (panneau gauche de l'éditeur), libellés d'affichage, e
|
||||
attributs par défaut posés à la création de chaque type.
|
||||
|
||||
## `CONTENT_KINDS: tuple[str, ...]`
|
||||
`("titre", "paragraphe", "image", "bouton")` — éléments du flux, peuvent
|
||||
être top-niveau ou enfants d'une rangée.
|
||||
`("titre", "paragraphe", "image", "bouton", "liste_puces",
|
||||
"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, ...]`
|
||||
`("quiz", "association", "memory", "mots", "scenario", "zones")` —
|
||||
@@ -28,13 +44,57 @@ l'affichage/debug hors bibliothèque).
|
||||
## `element_default_attributes(kind: str) -> dict[str, Any]`
|
||||
Attributs posés à la création d'un élément de ce type (voir
|
||||
`document_engine/elements/add_document_element.py`).
|
||||
- **Retour** : un dict d'attributs par défaut, dépendant du `kind` :
|
||||
texte (`content/style` + `bold/italic/underline/align/color`),
|
||||
image (`src/alt`), bouton (`label/target`), 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.
|
||||
- **Retour** : un dict d'attributs par défaut, dépendant du `kind` (les
|
||||
"attributs de boîte partagés" mentionnés ci-dessous — `padding/margin/
|
||||
background_color/border_radius/width/max_width/height/min_height/
|
||||
max_height/min_width/box_shadow/opacity/align_self/content_align/
|
||||
border` — sont toujours les mêmes, voir `rendering/box_style.py` :
|
||||
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.
|
||||
|
||||
## `quiz_config.py` — modèle de données du mini-jeu Quiz
|
||||
|
||||
@@ -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()
|
||||
@@ -7,8 +7,9 @@ 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. Un support a toujours au moins une page (voir
|
||||
db/supports/create_support.py / ensure_document_pages_schema)."""
|
||||
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()
|
||||
|
||||
@@ -4,13 +4,21 @@ 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 a
|
||||
TOUJOURS au moins une page (`create_support` en crée une par défaut,
|
||||
`ensure_document_pages_schema` en garantit une pour les supports plus
|
||||
anciens) — la garde "jamais supprimer la dernière page" est un
|
||||
garde-fou métier posé par l'appelant (voir
|
||||
`routes/document/document_page_delete.py`), pas une contrainte de ce
|
||||
paquet.
|
||||
(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 :
|
||||
@@ -43,14 +51,62 @@ 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 — cette règle
|
||||
est posée par l'appelant, pas par cette fonction bas niveau (même
|
||||
découpage que `routes/game/screens/screen_delete.py` côté jeu, où le
|
||||
garde-fou vit aussi dans la route).
|
||||
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,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,12 @@
|
||||
import html as html_lib
|
||||
import json
|
||||
import random
|
||||
import urllib.parse
|
||||
from typing import Any
|
||||
|
||||
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:
|
||||
"""Assemble le document ENTIER à partir de la liste à plat renvoyée
|
||||
@@ -49,29 +53,337 @@ def _render_text(el: dict[str, Any], _children_by_parent: dict[int | None, list[
|
||||
a = el["attributes"]
|
||||
content = html_lib.escape(str(a.get("content", "")))
|
||||
style_name = a.get("style", "paragraphe")
|
||||
font_size, base_weight, line_height = _STYLE_PRESETS.get(style_name, _STYLE_PRESETS["paragraphe"])
|
||||
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
|
||||
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")))
|
||||
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 = (
|
||||
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};"
|
||||
)
|
||||
|
||||
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>'
|
||||
|
||||
|
||||
_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:
|
||||
a = el["attributes"]
|
||||
click_behavior = str(a.get("click_behavior", ""))
|
||||
link_url = str(a.get("link_url", "")).strip()
|
||||
caption = str(a.get("caption", "")).strip()
|
||||
# render_box_style (padding/margin/fond/bordure/largeur/position du
|
||||
# bloc, dont align-self) doit se poser sur l'élément RÉELLEMENT
|
||||
# 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>'
|
||||
)
|
||||
else:
|
||||
src = html_lib.escape(str(a.get("src", "")))
|
||||
alt = html_lib.escape(str(a.get("alt", "")))
|
||||
if not src:
|
||||
return (
|
||||
f'<div class="docImage docImagePlaceholder" data-element-id="{el["id"]}" data-kind="image">'
|
||||
f"Image — aucun fichier choisi</div>"
|
||||
media = (
|
||||
f'<div class="docImage docImagePlaceholder" data-element-id="{el["id"]}" '
|
||||
f'data-kind="image"{media_style_attr}>Image — aucun fichier choisi</div>'
|
||||
)
|
||||
return f'<img class="docImage" data-element-id="{el["id"]}" data-kind="image" src="{src}" alt="{alt}">'
|
||||
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:
|
||||
@@ -79,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")))
|
||||
target = html_lib.escape(str(a.get("target", "")))
|
||||
target_attr = f' data-target="{target}"' if target else ""
|
||||
# `data-attachment-filename` sert UNIQUEMENT de marqueur mécanique : un
|
||||
# fichier a bien été joint (voir routes/document/
|
||||
# document_element_upload_attachment.py). L'URL de téléchargement
|
||||
# elle-même n'est jamais construite ici (ce renderer ne connaît pas le
|
||||
# slug du support) — static/document/js/document-editor.js l'assemble
|
||||
# à partir de `data-element-id` + FORGE_DOCUMENT.slug, même principe
|
||||
# que le reste des appels AJAX de l'éditeur.
|
||||
attachment_filename = html_lib.escape(str(a.get("attachment_filename", "")))
|
||||
attachment_attr = f' data-attachment-filename="{attachment_filename}"' if attachment_filename else ""
|
||||
|
||||
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 (
|
||||
f'<button type="button" class="docButton" data-element-id="{el["id"]}" data-kind="bouton"{target_attr}>'
|
||||
f"{label}</button>"
|
||||
f'<button type="button" class="docButton" data-element-id="{el["id"]}" '
|
||||
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>"
|
||||
)
|
||||
|
||||
|
||||
@@ -498,6 +871,10 @@ _RENDERERS = {
|
||||
"paragraphe": _render_text,
|
||||
"image": _render_image,
|
||||
"bouton": _render_button,
|
||||
"liste_puces": _render_list,
|
||||
"liste_numerotee": _render_list,
|
||||
"badge": _render_badge,
|
||||
"carte": _render_carte,
|
||||
"quiz": _render_quiz,
|
||||
"association": _render_association,
|
||||
"memory": _render_memory,
|
||||
|
||||
@@ -29,9 +29,125 @@ regroupement à chaque appel.
|
||||
`justify-content` réels depuis `attributes`), enfants rendus
|
||||
récursivement.
|
||||
- **Texte** (`titre`/`paragraphe`) : `<div>` stylé selon `style` (préréglage
|
||||
taille/graisse/interligne) et `bold`/`italic`/`underline`/`align`/`color`.
|
||||
- **Image** : `<img>`, ou un bloc placeholder si `src` est vide.
|
||||
- **Bouton** : `<button>` avec son `label` et un `data-target` optionnel.
|
||||
taille/graisse/interligne) et `bold`/`italic`/`underline`/`strikethrough`/
|
||||
`align`/`color`. `underline`/`strikethrough` se combinent dans un seul
|
||||
`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
|
||||
questions, total des points via `quiz_total_points`, minuteur si
|
||||
activé) — sanitizée (`sanitize_quiz_config`) avant lecture, jamais un
|
||||
@@ -141,3 +257,88 @@ regroupement à chaque appel.
|
||||
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,12 +8,21 @@ from . import ( # noqa: F401 - enregistre les routes definies dans chaque modul
|
||||
document_edit,
|
||||
document_element_add,
|
||||
document_element_delete,
|
||||
document_element_download_attachment,
|
||||
document_element_move,
|
||||
document_element_move_to_page,
|
||||
document_element_update,
|
||||
document_element_upload_attachment,
|
||||
document_element_upload_image,
|
||||
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_theme_apply,
|
||||
document_theme_preview,
|
||||
document_uploaded_file,
|
||||
)
|
||||
|
||||
@@ -37,12 +37,23 @@ def document_edit(slug: str) -> str:
|
||||
constaté le 21/09/2026)."""
|
||||
support = db.support_meta(slug)
|
||||
pages = document_engine.list_document_pages(slug)
|
||||
active_page = pages[0]
|
||||
# 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]] = [
|
||||
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(
|
||||
"document/document_edit.html",
|
||||
@@ -53,4 +64,18 @@ def document_edit(slug: str) -> str:
|
||||
rendered_document=document_engine.render_document(active_elements),
|
||||
element_library=document_engine.ELEMENT_LIBRARY,
|
||||
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
|
||||
],
|
||||
)
|
||||
|
||||
@@ -26,6 +26,22 @@ def document_element_add(slug: str) -> Response | tuple[Response, 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)
|
||||
|
||||
# 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(
|
||||
document_engine.get_document_element(slug, element_id),
|
||||
|
||||
@@ -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})
|
||||
@@ -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),
|
||||
}
|
||||
)
|
||||
@@ -7,14 +7,12 @@ 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 — refuse de supprimer la DERNIÈRE page restante
|
||||
(un support a toujours au moins une page, voir document_engine/pages/
|
||||
pages.md) : le garde-fou vit ici, à la route, pas dans
|
||||
delete_document_page (même découpage que routes/game/screens/
|
||||
screen_delete.py côté jeu, qui bloque aussi au niveau route)."""
|
||||
"""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
|
||||
if len(document_engine.list_document_pages(slug)) <= 1:
|
||||
return jsonify({"error": "impossible de supprimer la dernière page"}), 400
|
||||
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,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})
|
||||
@@ -20,10 +20,19 @@ def document_render(slug: str) -> Response | tuple[Response, int]:
|
||||
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 or document_engine.get_document_page(slug, page_id) is None:
|
||||
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})
|
||||
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)
|
||||
File diff suppressed because it is too large
Load Diff
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 %}
|
||||
<link rel="stylesheet"
|
||||
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 %}
|
||||
{% block content %}
|
||||
<div class="docEditor3" id="docEditor3" data-slug="{{ support.slug }}">
|
||||
@@ -76,6 +83,23 @@
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg><span class="docBtnLabel">Aperçu</span>
|
||||
</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="docWidthPicker"
|
||||
id="docWidthPicker"
|
||||
@@ -123,6 +147,15 @@
|
||||
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 '' }}"
|
||||
@@ -131,29 +164,15 @@
|
||||
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 (réordonner/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. #}
|
||||
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="docPageRowMove"
|
||||
data-dir="up"
|
||||
data-page-id="{{ page.id }}"
|
||||
aria-label="Monter"
|
||||
title="Monter"
|
||||
{{ 'disabled' if loop.first else '' }}>↑</button>
|
||||
<button type="button"
|
||||
class="docPageRowMove"
|
||||
data-dir="down"
|
||||
data-page-id="{{ page.id }}"
|
||||
aria-label="Descendre"
|
||||
title="Descendre"
|
||||
{{ 'disabled' if loop.last else '' }}>↓</button>
|
||||
<button type="button"
|
||||
class="docPageRowRename"
|
||||
data-page-id="{{ page.id }}"
|
||||
@@ -163,13 +182,11 @@
|
||||
class="docPageRowDelete"
|
||||
data-page-id="{{ page.id }}"
|
||||
aria-label="Supprimer cette page"
|
||||
title="Supprimer cette page"
|
||||
{{ 'disabled' if pages|length <= 1 else '' }}>✕</button>
|
||||
title="Supprimer cette page">✕</button>
|
||||
</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<button type="button" class="docBtnSecondary docPageManagerAdd" id="docPageManagerAdd">+ Ajouter une page</button>
|
||||
</div>
|
||||
<div class="docSidebarTabPanel" id="docTabPanelLayout" role="tabpanel" aria-labelledby="docTabLayout">
|
||||
{% for category_key, category in element_library.items() %}
|
||||
@@ -191,9 +208,16 @@
|
||||
</div>
|
||||
<div class="docCanvasArea" id="docCanvasArea">
|
||||
<div class="docPage" id="docPage">
|
||||
<div class="docPageContent" id="docPageContent">{{ rendered_document|safe }}</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 #}
|
||||
<div class="docPageContent"
|
||||
id="docPageContent"
|
||||
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>
|
||||
<button type="button"
|
||||
class="docPreviewExitBtn"
|
||||
id="docPreviewExitBtn"
|
||||
aria-label="Retour à l'éditeur"
|
||||
title="Retour à l'éditeur">✕ Retour à l'éditeur</button>
|
||||
<div class="docZoomPill">
|
||||
<button type="button" id="docZoomOut" aria-label="Zoom -">−</button>
|
||||
<span id="docZoomVal">100%</span>
|
||||
@@ -231,13 +255,43 @@
|
||||
<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>
|
||||
window.FORGE_DOCUMENT = {
|
||||
slug: {{ support.slug|tojson }},
|
||||
elements: {{ elements|tojson }},
|
||||
elementKindLabels: {{ element_kind_labels|tojson }},
|
||||
pages: {{ pages|tojson }},
|
||||
activePageId: {{ active_page.id|tojson }}
|
||||
activePageId: {{ (active_page.id if active_page else none)|tojson }},
|
||||
themes: {{ document_themes|tojson }},
|
||||
activeThemeId: {{ support.theme|tojson }}
|
||||
};
|
||||
</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
|
||||
(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
|
||||
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 document_engine
|
||||
|
||||
resp = client.post("/documents/new", data={"name": "pytest_test_support"}, follow_redirects=False)
|
||||
assert resp.status_code == 302
|
||||
slug = resp.headers["Location"].rstrip("/").split("/")[-2]
|
||||
document_engine.add_document_page(slug)
|
||||
yield slug
|
||||
if os.path.isdir(db.supports.support_dir(slug)):
|
||||
db.delete_support(slug)
|
||||
|
||||
@@ -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
@@ -1,7 +1,10 @@
|
||||
"""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". Un support a toujours au moins une page
|
||||
(créée par défaut avec le support, voir la fixture `support`)."""
|
||||
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
|
||||
|
||||
@@ -50,12 +53,29 @@ def test_document_page_rename_rejects_an_unknown_page(client: FlaskClient, suppo
|
||||
assert client.post(f"/document/{support}/pages/999/rename", json={"title": "X"}).status_code == 404
|
||||
|
||||
|
||||
def test_document_page_delete_refuses_the_last_remaining_page(client: FlaskClient, support: str) -> None:
|
||||
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 == 400
|
||||
assert "error" in resp.get_json()
|
||||
assert document_engine.get_document_page(support, page_id) is not None
|
||||
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:
|
||||
@@ -93,3 +113,33 @@ def test_document_page_move_reorders_the_tab_strip(client: FlaskClient, support:
|
||||
|
||||
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
|
||||
|
||||
@@ -8,6 +8,7 @@ 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 flask.testing import FlaskClient
|
||||
@@ -38,6 +39,19 @@ def test_document_edit_renders_directly_no_intermediate_page(client: FlaskClient
|
||||
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:
|
||||
resp = client.post(f"/document/{support}/elements/add", data={"kind": "titre", "page_id": _page_id(support)})
|
||||
assert resp.status_code == 200
|
||||
@@ -69,6 +83,25 @@ def test_document_element_add_accepts_row_for_the_layout_engine(client: FlaskCli
|
||||
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:
|
||||
page_id = _page_id(support)
|
||||
add_resp = client.post(f"/document/{support}/elements/add", data={"kind": "paragraphe", "page_id": page_id})
|
||||
@@ -204,6 +237,60 @@ def test_document_render_rejects_a_missing_or_unknown_page(client: FlaskClient,
|
||||
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:
|
||||
page_id = _page_id(support)
|
||||
first_id = client.post(f"/document/{support}/elements/add", data={"kind": "titre", "page_id": page_id}).get_json()[
|
||||
@@ -225,6 +312,35 @@ def test_document_element_move_and_delete(client: FlaskClient, support: str) ->
|
||||
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(
|
||||
user_client: FlaskClient, tmp_game_slug_cleanup: Any, tmp_support_slug_cleanup: Any
|
||||
) -> None:
|
||||
@@ -257,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
|
||||
finally:
|
||||
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,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("") == ""
|
||||
@@ -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"
|
||||
|
||||
|
||||
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(
|
||||
tmp_support_slug_cleanup: Any, tmp_game_slug_cleanup: Any
|
||||
) -> 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]
|
||||
db.delete_support(slug)
|
||||
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)
|
||||
|
||||
# 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