Compare commits

...
56 Commits
Author SHA1 Message Date
williamandClaude Sonnet 5 e9d7f560e3 Inclut document_theme_apply.py oublié du commit précédent
Build and deploy / test-python (push) Successful in 11m34s
Build and deploy / test-js (push) Successful in 1m13s
Build and deploy / lint-python (push) Successful in 5m54s
Build and deploy / lint-js (push) Failing after 1m41s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Build and deploy / sonarqube (push) Failing after 4m49s
La route de retrait de thème (theme_id vide) fait partie intégrante de
la fonctionnalité "Aucun modèle" du commit précédent — fichier omis par
erreur du git add initial.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-26 15:13:53 +02:00
williamandClaude Sonnet 5 4db1269348 Audit complet de mise en forme — Liste à puces/numérotée (4e élément)
Implémente toutes les options manquantes identifiées pour les listes :
typographie complète, style/position/couleur/taille de puce (validés
selon le kind), puce personnalisée en SVG pour les listes à puces,
padding uniforme par élément (nouveau, il n'y en avait aucun), espacement
entre éléments réglable, et tous les attributs de boîte partagés sur la
liste entière. Bordure/fond/padding par élément individuel et sous-listes
imbriquées volontairement différés (portée actée avec l'utilisateur
avant implémentation : transformeraient le stockage des éléments en
objets structurés, chantier bien plus lourd).

Trois ajouts transversaux bénéficiant à plusieurs éléments : sections
"Contenu"/"Conteneur" dans tous les panneaux de propriétés, alignement
vertical du contenu dans son bloc (Titre/Paragraphe/Liste/Image
légendée), et une option pour retirer un thème appliqué ("Aucun modèle"
dans la modale, avec une nouvelle fonction db.remove_document_theme).

Quatre bugs réels trouvés et corrigés en chaîne pendant la validation
avec le thème "Sécurité incendie" : un badge de thème s'affichait
au-dessus du texte au lieu d'à côté ; le correctif a d'abord fait
disparaître les puces/numéros natifs de TOUTES les listes (bug plus
grave que celui corrigé) ; puis un marqueur natif redondant apparaissait
à côté du badge du thème ; puis une règle CSS site-large de spécificité
supérieure empêchait silencieusement ce dernier correctif. Chaque étape
vérifiée par navigateur automatisé sur un support jetable.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-26 15:13:14 +02:00
williamandClaude Sonnet 5 8f879f6321 Audit complet de mise en forme — Bouton (3e élément du tableau)
Implémente toutes les options manquantes identifiées pour l'élément
Bouton : typographie (gras/italique/transformation/police/taille/
espacement des lettres/couleur), icône SVG optionnelle avec position
(avant/après le texte) et taille réglables — au-delà de l'Étiquette,
qui n'a qu'une icône fixe —, tous les attributs de boîte partagés déjà
créés pour Titre/Paragraphe/Image, et un effet visuel universel au
survol/clic.

Deux ajouts transversaux demandés pendant le test : un réglage "les 4
côtés de la bordure à la fois" dans le module de boîte partagé
(bénéficie automatiquement à tous les éléments qui l'utilisent), et le
glisser-déposer d'un élément du canevas vers une autre page via le
panneau Pages (le mécanisme serveur existait déjà pour la pagination
automatique, seule l'interaction manuelle manquait).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-26 12:47:46 +02:00
williamandClaude Sonnet 5 9ad50c58b8 Audit complet de mise en forme — Image (2e élément du tableau)
Implémente toutes les options manquantes identifiées pour l'élément
Image : dimensionnement/ratio/object-fit, filtres CSS, upload de
fichier (en plus de l'URL), lien/plein écran au clic, chargement
différé, légende, et tous les attributs de boîte partagés déjà créés
pour Titre/Paragraphe (padding/margin/fond/bordure/ombre/opacité/
position du bloc).

Système de pages : un support peut désormais avoir 0 page (un nouveau
support démarre vide), suppression de toutes les pages en un clic, et
la pagination automatique insère intelligemment la nouvelle page juste
après celle qui déborde plutôt qu'en toute fin de liste.

Bugs réels trouvés et corrigés en cours de route : le style de bloc
(dont align-self) ciblait l'élément interne au lieu de son enveloppe
(légende/lien) ; une image à sa taille native pouvait déclencher une
pagination infinie ; upload/mise à jour d'attribut ne déclenchaient
jamais le contrôle de débordement.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-26 12:06:44 +02:00
williamandClaude Sonnet 5 6c7675fad0 Audit complet de mise en forme — Titre/Paragraphe (1er élément du tableau)
Nouveau module partagé document_engine/rendering/box_style.py
(padding/margin/background_color/border_radius/border par côté/height/
min-height/max-height/min-width/box-shadow/opacity/align_self) —
réutilisable tel quel par tous les kinds suivants du tableau d'audit.

Titre/Paragraphe gagnent : barré, police de caractère, taille de
police, hauteur de ligne, espacement des lettres, majuscules/
minuscules/capitales, ombre du texte, et tous les attributs de boîte
partagés ci-dessus. Interface entièrement à base de curseurs/cases à
cocher/listes déroulantes/sélecteurs de couleur natifs — plus aucun
champ de texte libre pour une valeur CSS (retour utilisateur).

Deux bugs transversaux corrigés au passage (concernent tout
l'éditeur) :
- Le panneau Propriétés n'était jamais reconstruit après un clic sur un
  bouton (gras/alignement/segments...) — il fallait recharger la page
  pour voir l'état réel. Corrigé dans forgeDocUpdateAttributes, point
  d'entrée unique de toute mise à jour d'attribut.
- Les cases à cocher et curseurs héritaient à tort le style d'un champ
  de texte (padding/bordure/fond/largeur 100%) via la règle générique
  .docField input.

Ajoute docs/plan/AUDIT_MISE_EN_FORME.md : suivi de l'audit élément par
élément (Titre/Paragraphe traité, Image ensuite).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-26 09:25:30 +02:00
williamandClaude Sonnet 5 e03bea39c5 Ajoute icône/largeur/arrondi/gras/majuscules à l'étiquette (badge)
Nouveaux attributs par élément (svg_markup/width/border_radius/bold/
uppercase, tous vides ou False par défaut = comportement historique
inchangé), réglables depuis le panneau Propriétés — même esprit que
bold/align/color déjà présents sur titre/paragraphe. Fixer une largeur
implique toujours de sortir de l'étirement pleine largeur par défaut
(align-self:flex-start posé automatiquement avec elle).

Le kicker "Module obligatoire" du thème Sécurité Incendie s'en sert
maintenant (icône flamme, largeur au contenu, arrondi complet, gras,
majuscules) pour être conforme à la maquette d'origine — la puce
décorative CSS générique qui la remplaçait disparaît du thème.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 11:36:30 +02:00
williamandClaude Sonnet 5 1296edc2f8 Ajoute une largeur maximale optionnelle aux blocs de texte (titre/paragraphe)
Nouvel attribut max_width (vide par défaut = pleine largeur, inchangé)
sur les kinds titre/paragraphe, réglable depuis leur panneau
Propriétés. Le sous-titre de la page de garde du thème Sécurité
Incendie l'utilise (60ch) pour rester conforme à la maquette d'origine,
qui ne l'étirait pas sur toute la largeur de la page.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 10:39:18 +02:00
williamandClaude Sonnet 5 746feb3796 Ajoute l'alignement vertical du contenu d'une page, réglable depuis l'onglet "Pages"
Nouvelle colonne _document_pages.vertical_align (top/center/bottom,
"top" par défaut, migration incluse pour les supports existants).
Quand l'onglet "Pages" du panneau gauche est actif, le panneau
Propriétés (droite) affiche maintenant l'alignement de la page active
au lieu des propriétés d'un élément — un contrôle segmenté qui persiste
via une nouvelle route dédiée et met à jour le canevas immédiatement.

Le contenu-seed des thèmes porte désormais aussi ce réglage par page
(seed_pages devient une liste de {vertical_align, blocks} plutôt qu'une
liste de listes de blocs) : la page de titre du thème "Sécurité
Incendie" est centrée verticalement, comme demandé, cohérente avec la
maquette d'origine. L'aperçu de thème (iframe de la modale) reflète
aussi ce réglage par page.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 10:24:48 +02:00
williamandClaude Sonnet 5 be62275675 Met la page entière à l'échelle dans l'aperçu de modèle, sans barre de défilement
La page a une largeur/un ratio fixes côté moteur (960px, A4 paysage) —
sans mise à l'échelle, elle débordait verticalement de la fenêtre
d'aperçu (plus petite qu'un canevas d'édition en plein écran) et
défilait/rognait au lieu de tenir entière. Un script calcule maintenant
le facteur d'échelle qui la fait toujours tenir en entier (transform:
scale, jamais un agrandissement au-delà de 1), recalculé au
redimensionnement.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 10:02:47 +02:00
williamandClaude Sonnet 5 7730b3688f Corrige la modale "Utiliser un modèle" : aucune sélection par défaut, aperçu plein espace, navigation entre pages
Trois retours distincts :
- Plus de présélection du thème déjà appliqué à l'ouverture — un choix
  toujours explicite de l'utilisateur.
- L'état vide (.docTemplatePreviewEmpty) restait visible EN MÊME TEMPS
  que l'iframe une fois un thème sélectionné : `display:flex` posé
  directement dessus battait le `display:none` natif de [hidden]
  (même bug déjà rencontré pour .docSidebarTabPanel[hidden] plus tôt
  dans le projet) — les deux se partageaient flex:1, coupant l'aperçu
  en deux au lieu de lui laisser tout l'espace.
- L'aperçu ne montrait que la première page du modèle sans aucun moyen
  d'en voir les autres : la route /document/<slug>/theme/<id>/preview
  rend désormais TOUTES les pages, une barre Précédent/Suivant
  (entièrement côté client, aucun aller-retour serveur supplémentaire)
  permet de naviguer entre elles.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 09:58:31 +02:00
williamandClaude Sonnet 5 bd8f1d4b1e Modale "Utiliser un modèle" en plein écran, deux colonnes égales
Demande explicite : la modale occupe maintenant tout l'écran (100vw/
100vh, plus de padding/coins arrondis hérités de la modale générique),
divisée en deux colonnes strictement égales (grid-template-columns:
1fr 1fr) — la liste des thèmes devient une grille de cartes à gauche,
l'aperçu occupe toute la colonne de droite.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 09:43:22 +02:00
williamandClaude Sonnet 5 56afe77bd4 Corrige la modale "Utiliser un modèle" : fond transparent et aperçu minuscule
Deux bugs distincts :
- #docTemplateModal n'était jamais ajoutée au sélecteur qui définit les
  tokens --doc-* (--doc-bg-2/--doc-border/--doc-text/...) — ces tokens
  n'existaient nulle part sur elle, donc toutes les couleurs de fond de
  la modale résolvaient à rien (transparence totale). Corrigé en
  l'ajoutant à ce sélecteur (et au data-theme posé par
  forgeDocApplyTheme, pour suivre le thème clair/sombre de l'éditeur).
- .docModalDialog--wide n'avait qu'un max-height, jamais un height
  explicite : le dialogue flex se limitait à la hauteur naturelle de
  son contenu, et l'iframe d'aperçu (flex:1) retombait à sa hauteur
  intrinsèque minuscule faute de référence pour se déployer.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 09:35:20 +02:00
williamandClaude Sonnet 5 09449fe911 Corrige le style du thème Sécurité Incendie — plusieurs éléments n'avaient aucune mise en page par défaut
Signalé par capture d'écran : le rendu réel ne ressemblait pas du tout
à la maquette. Causes trouvées :
- .docImage (mode SVG inline) et .docCard n'ont AUCUNE règle de mise en
  page côté moteur (seuls .docImagePlaceholder et <img class="docImage">
  en ont une) — le thème doit leur donner leur forme, pas seulement leurs
  couleurs.
- --doc-accent/--doc-accent-2 (barre de progression et survol du Quiz)
  n'étaient jamais redéfinis, gardant l'orange générique du chrome de
  l'éditeur au lieu du rouge du thème.
- Le liseré décoratif en haut de page et la puce de l'étiquette (icône)
  avaient été omis en pensant, à tort, qu'ils relevaient du FORMAT de
  .docPage — ce ne sont que des flourishes visuels, aucun rapport avec
  la taille/le format A4 paysage qui doit rester intouchable.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 09:25:32 +02:00
williamandClaude Sonnet 5 64ee7292d4 Documente document_engine/themes/ et replace_document_content, corrige la liste des sous-dossiers dans document_engine.md
document_engine.md listait encore seulement 3 sous-dossiers alors que
pages/ existait déjà avant cette session — corrigé au passage.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 09:14:29 +02:00
williamandClaude Sonnet 5 864ae697fd Ajoute le système de modèles/thèmes de document ("Utiliser un modèle")
Nouveau bouton dans le topbar de l'éditeur, à côté d'Aperçu, qui ouvre
une modale listant les thèmes du catalogue (document_engine/themes/).
Cliquer un thème charge un VRAI aperçu (rendu serveur réel dans un
iframe, jamais une resucée CSS côté client) avec le choix de garder le
contenu actuel ou de le remplacer par le contenu de démonstration du
modèle.

Architecture pensée pour une centaine de thèmes futurs : chaque thème
est une feuille de style externe (static/document/themes/<id>.css) qui
habille les classes fixes du moteur, jamais du code qui en changerait
la structure. Premier thème implémenté pour valider le mécanisme :
"Sécurité Incendie" (6 pages de contenu réel, quiz inclus).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 09:12:57 +02:00
williamandClaude Sonnet 5 a34bcf4159 Ajoute 4 mécanismes moteur manquants pour le thème sécurité incendie : étiquette, carte, image SVG inline, bouton avec pièce jointe
Contenu et mécanisme uniquement, aucun style ajouté (voir consigne du
24/09/2026) : deux nouveaux kinds de contenu (badge/carte, rendu en
div brutes sans CSS), un mode SVG inline pour l'image (svg_markup,
nettoyé par un nouveau sanitizer allow-list avant chaque rendu) et un
fichier téléchargeable joignable à un bouton (upload/download routes,
stockage sous db.support_dir). Le futur système de templates portera
l'habillage visuel de ces éléments.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 08:12:29 +02:00
williamandClaude Sonnet 5 e7c6ed7159 Ajoute les listes à puces et numérotées dans la bibliothèque de contenu
Retour utilisateur du 24/09/2026 : "dans la section contenu il manque
la possibilité d'utiliser une liste à puce ou ordonnée".

- Deux nouveaux kinds "liste_puces"/"liste_numerotee" dans
  CONTENT_KINDS, chacun sélectionnable directement dans la bibliothèque
  (comme titre/paragraphe/image/bouton) — partagent la même structure
  d'attributs (`items`, une liste de chaînes), c'est le kind lui-même
  qui décide <ul> ou <ol> au rendu (_render_list), pas un attribut
  "ordered" redondant à tenir synchronisé.
- Rendu : un <li> par item, échappé (html.escape) comme tout le
  contenu texte du document — une liste vide rend <ul>/<ol> sans
  enfant plutôt qu'un placeholder (état normal, pas une image sans
  fichier).
- Panneau Propriétés : même patron répéteur que l'Association/Memory
  (ajouter/renommer/supprimer une ligne, rechargé après chaque
  modification).
- Icônes de bibliothèque (☰/①) + style .docList (puces/numéros
  visibles, espacement entre items).

6 nouveaux tests Python (attributs par défaut, rendu <ul>/<ol>,
échappement, liste vide, route d'ajout) + vérifié par un test jsdom
dédié contre un vrai support (bibliothèque, panneau Propriétés :
ajout/modification/suppression d'item réellement fonctionnels).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 07:19:01 +02:00
williamandClaude Sonnet 5 eac45f1c9d Corrige la vraie cause du plein écran incomplet : max-height du format A4 oublié en Aperçu
Diagnostic console à l'appui (document.fullscreenElement confirmé
actif, .docEditor3/.docBodyWrap/.docCanvasArea mesurés à 960px =
window.innerHeight, mais .docPage seule à 678.78px) : ma précédente
tentative (width/height:100vh sur .docEditor3) ciblait le mauvais
niveau — toute la chaîne jusqu'à .docCanvasArea remplissait déjà
correctement l'écran. Le vrai plafond venait de max-height posé sur
.docPage pour le format A4 en édition (960 * 210/297 ≈ 678.79px,
exactement la valeur mesurée) : la règle Aperçu changeait bien
width/height/aspect-ratio mais oubliait max-height, qui continue de
gagner sur height:100% quelle que soit sa valeur. Neutralisé
(max-height:none, min-height:0) uniquement dans la règle Aperçu — le
plafond A4 reste actif en édition.

Vérifié par getComputedStyle (max-height résolu à "none" en Aperçu,
toujours actif en édition).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 07:05:30 +02:00
williamandClaude Sonnet 5 b633126a7e 3 correctifs Aperçu : bords carrés des mini-jeux, titres en noir, vrai plein écran
Capture utilisateur à l'appui du 24/09/2026.

- border-radius:0 sur toutes les cartes/boutons/cases du JOUEUR de
  mini-jeu (.docMinigame, .docQuizCard/.docQuizResultCard,
  .docQuizOption, .docQuizFeedback, .docQuizNextBtn/.docQuizRestartBtn,
  .docAssocCard, .docAssocItem/.docAssocSlot,
  .docMinigameRestartBar button, .docMemoryCardWrap/.docMemoryCardFace,
  .docMotsCell/.docMotsWordChip) — "les bord des mini jeu ne doivent
  plus etre arrondie". Le badge rond A/B (.docQuizOptionLetter, un
  cercle volontaire) et le graphe d'édition du Scénario (outil de
  configuration, pas le joueur) restent hors scope.
- Bug réel trouvé : .docQuizPlayerTitle/.docQuizQuestionText/
  .docAssocTitle (et autres titres sans `color` propre) restaient
  quasi invisibles sur la page blanche — `color` est une propriété
  HÉRITÉE, et redéfinir la custom property --doc-text sur .docPage (fait
  la session précédente) ne "recoupe" pas une couleur DÉJÀ CALCULÉE plus
  haut sur .docEditor3 (palette sombre). Ajoute color:var(--doc-text)
  explicitement sur .docPage, qui relance la résolution avec la bonne
  valeur locale pour tout descendant sans `color` propre.
- La page ne remplissait pas toute la hauteur en Aperçu (bande noire en
  bas) : .docEditor3 s'appuie sur flex:1 1 auto pour sa taille, valide
  seulement comme enfant d'un flex container normal — une fois
  réellement en plein écran (peint hors du flux normal par le
  navigateur), ce mécanisme perd son contexte. width/height explicites
  en secours sur .docEditor3:fullscreen et .docEditor3.docEditor3--preview.

Vérifié par getComputedStyle (border-radius à 0, --doc-text résolu en
sombre au niveau de .docPage, dimensions 100vw/100vh en preview).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 06:45:17 +02:00
williamandClaude Sonnet 5 fff098c60c Style des mini-jeux revu pour la page blanche fixe
Retour utilisateur du 24/09/2026 : "le style des mini jeux dois etre
revue pour etre sur fond blanc" — les cartes/options/boutons des
mini-jeux (Quiz/Association/Memory/Mots mêlés/Scénario) s'appuient sur
les tokens --doc-* (--doc-card, --doc-bg-2, --doc-text, --doc-border,
succès/échec du Quiz), sombres par défaut et assortis au CHROME de
l'éditeur plutôt qu'à la page. Redéfinit ces tokens dans le scope de
.docPage avec les mêmes valeurs déjà établies pour
.docEditor3[data-theme="light"] (palette claire déjà conçue et
éprouvée dans ce fichier, pas une troisième version inventée) : la
page étant désormais toujours blanche, son contenu utilise toujours
cette palette, peu importe le thème choisi pour le chrome autour.

Vérifié par getComputedStyle (tokens résolus en valeurs claires dans
le scope de .docPage).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 06:32:30 +02:00
williamandClaude Sonnet 5 71e6302503 La page du document est blanche fixe, en édition ET en Aperçu
Retour utilisateur du 24/09/2026 : "je veux que la page dans l'éditeur
et l'apercu soi blanche comme dans tous éditeur normale" — .docPage
utilisait var(--doc-card), qui suit le thème clair/sombre de
L'ÉDITEUR (chrome autour), pas de la page elle-même. Passe à #fff
fixe. Redéfinit --forge-text/--forge-text-muted dans le scope de
.docPage (le texte de contenu par défaut écrit color:var(--forge-text)
en style inline, qui vaut #e8ecf4 quasi blanc partout ailleurs dans le
site — illisible sur blanc sans cette redéfinition locale). Les
mini-jeux (options quiz, cartes association...) restent lisibles sans
changement : ils ont chacun leur propre fond sombre (--doc-bg-2),
indépendant du fond de la page. Vérifié par getComputedStyle.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 06:26:45 +02:00
williamandClaude Sonnet 5 7cb8986f58 Empêche le contenu de se compresser pour tenir sur la page — il doit déborder pour déclencher la pagination
Capture utilisateur à l'appui du 23/09/2026 : en ajoutant un 3e bloc,
les images (pourtant height:190px) rétrécissaient visiblement au lieu
d'aller sur une nouvelle page. Cause réelle : flex-shrink vaut 1 par
défaut pour tout enfant flex — une fois .docPage plafonnée en hauteur
(commit précédent), les enfants top-niveau de .docPageContent se
compressaient tous pour continuer à tenir, sans jamais réellement
déborder. Sans ce débordement réel, forgeDocCheckPageOverflow
(scrollHeight vs clientHeight) ne détectait jamais rien à paginer.
flex-shrink:0 sur .docPageContent > [data-element-id] : le contenu
garde sa taille naturelle et déborde franchement quand il n'y a plus
de place, ce qui déclenche la pagination automatique.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 12:04:46 +02:00
williamandClaude Sonnet 5 6056a263da La page ne grandit plus verticalement avec son contenu ; les mots trop longs se coupent
- .docPage n'avait que aspect-ratio pour dériver sa hauteur depuis sa
  largeur — insuffisant en pratique : la page grandissait pour
  accueillir tout le contenu au lieu de le clipser (overflow:hidden)
  et laisser forgeDocCheckPageOverflow gérer la pagination (retour
  utilisateur du 23/09/2026 : "le contenu ne dois jamais s'adapter
  verticalement"). Ajoute un filet de sécurité : max-height calculé
  explicitement (calc() à partir d'une nouvelle variable
  --doc-page-width, source unique partagée avec width et le mode
  aperçu à largeur fixe) + min-height:0 explicite, qui plafonnent
  la hauteur quoi qu'il arrive.
- .docText n'avait aucune gestion de mot trop long sans espace —
  débordait hors de la page au lieu de se couper (retour utilisateur
  du 23/09/2026 : "les élément paragraphe ne vont pas a la ligne").
  Ajoute overflow-wrap:break-word (jamais word-break:break-all, qui
  casserait aussi les mots normaux sans raison).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 11:56:10 +02:00
williamandClaude Sonnet 5 c6e173589f Pagination automatique : le contenu qui déborde part sur une nouvelle page
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 et amener l'utilisateur sur la page" — remplace le
comportement précédent (overflow:hidden, contenu clipsé, à gérer
manuellement).

- Nouvelle capacité serveur : document_engine.move_document_element_to_page
  (+ route POST /document/<slug>/elements/<id>/move-to-page) déplace un
  élément (et ses enfants de rangée en cascade) vers une AUTRE page —
  jusqu'ici move_document_element ne gérait que le réordonnancement DANS
  la même page.
- Client : forgeDocCheckPageOverflow, appelée à la fin de CHAQUE
  forgeDocRefreshCanvas (point d'entrée unique après toute mutation) :
  mesure le débordement réel (scrollHeight vs clientHeight), trouve le
  premier élément top-niveau qui dépasse le bas de la page
  (getBoundingClientRect, tient compte du zoom), déplace cet élément et
  tout ce qui le suit vers une page neuve, puis y bascule l'utilisateur.
  Jamais déclenché sur une page mini-jeu (toujours seule sur sa page,
  aucun débordement pertinent à corriger).

Vérifié par un test jsdom dédié (géométrie simulée via
getBoundingClientRect/scrollHeight/clientHeight, jsdom n'ayant pas de
vrai moteur de mise en page) : ordre des déplacements, page inchangée
si le contenu tient, page mini-jeu jamais scindée. 6 nouveaux tests
Python (document_engine + route). 711/711 tests passent.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 11:50:23 +02:00
williamandClaude Sonnet 5 0e8c6efb06 Retire l'espace entre le cadre du mini-jeu et le bord de sa page
Précision utilisateur du 23/09/2026 : pas les marges du canevas
(déjà annulées, "remet la page comme elle était") mais celles À
L'INTÉRIEUR de la page — le padding:24px posé sur .docQuizPlayer/
.docAssocPlayer/.docMemoryPlayer/.docMotsPlayer/.docScenarioPlayer
lors du centrage créait un espace visible entre le composant
(.docAssocCard etc.) et le bord de .docPage. Retiré : la carte touche
désormais les 4 bords de la page (son propre padding interne, 1.8rem,
reste intact pour la lisibilité de son contenu).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 11:29:33 +02:00
william cf349be030 Revert "Retire les marges du canevas autour d'une page mini-jeu, même hors Aperçu"
This reverts commit 7a0efe5c63.
2026-09-23 11:26:05 +02:00
williamandClaude Sonnet 5 7a0efe5c63 Retire les marges du canevas autour d'une page mini-jeu, même hors Aperçu
.docCanvasArea gardait son padding confortable (36px 40px) autour
d'une page mini-jeu en mode édition — seul .docPage lui-même (son
padding interne) avait été mis à plat jusqu'ici. Nouvelle règle
:has() conditionnée à la présence d'un mini-jeu, jamais globale : une
page de contenu normal garde ses marges habituelles. Vérifié par
getComputedStyle (0 avec mini-jeu, 36px 40px sans).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 11:22:36 +02:00
williamandClaude Sonnet 5 20ee8ef35b Aperçu en plein écran réel, sans espace autour du mini-jeu, contenu aligné en haut
- Mode Aperçu bascule désormais en VRAI plein écran (Fullscreen API,
  forgeDocSetPreviewMode) au lieu d'un simple agrandissement CSS —
  contourne d'un coup le clipping par overflow:hidden de main.content
  documenté ailleurs dans ce fichier. .docTopbar est masqué comme le
  reste du chrome ; un nouveau bouton flottant .docPreviewExitBtn
  (visible seulement en Aperçu) permet de revenir à l'éditeur, en plus
  d'Échap (natif, resynchronisé via fullscreenchange).
- .docCanvasArea perd son padding et .docPage abandonne son format A4
  fixe en Aperçu (width/height:100%, aspect-ratio:unset) — le mini-jeu
  remplit tout l'écran, sans bordure vide autour (retour utilisateur
  du 23/09/2026 : "il doit prendre toute la place").
- Le contenu des cartes de mini-jeu (.docQuizCard/.docAssocCard/
  .docMemoryCardWrap) passe de justify-content:center à flex-start —
  aligné en haut, pas centré verticalement (retour utilisateur du
  23/09/2026 : "le contenu aligner en haut").

Vérifié par getComputedStyle dans les deux modes (padding/aspect-ratio
de la page, visibilité du bouton de sortie et du bandeau, alignement
du contenu, gating pointer-events des mini-jeux — tout reste cohérent
simultanément).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 11:18:12 +02:00
williamandClaude Sonnet 5 152d10fc33 Retire complètement le badge (nom/"2 paires") d'une page mini-jeu plein-page
Le badge (.docMinigameBadge) restait visible au-dessus du joueur même
quand le mini-jeu occupe seul toute la page — retour utilisateur du
23/09/2026 : "ces éléments [...] doivent disparaitre et le composant
mini jeu pren toute la place". display:none (au lieu d'un simple
flex-shrink:0) : le joueur récupère toute la hauteur libérée. Vérifié
par getComputedStyle (badge display:none, joueur flex-grow:1,
gating pointer-events/plein-cadre de la page toujours corrects).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 11:08:05 +02:00
williamandClaude Sonnet 5 fadddb7113 Le mini-jeu remplit toute la hauteur/largeur du document, pas juste une carte centrée
Retour utilisateur du 23/09/2026 : une carte à largeur confortable
(même centrée) ne suffisait pas, "le mini jeu dois prendre toute la
hauteur et la largeur du document". Retire le max-width des cartes
internes (.docQuizCard/.docQuizResultCard/.docAssocCard/
.docMemoryCardWrap) — leur fond/bordure couvre désormais toute la
page (width:100% + align-items:stretch côté joueur pour la hauteur) —
et centre leur CONTENU à l'intérieur via display:flex +
justify-content:center sur la carte elle-même, plutôt que de le
laisser collé en haut d'une grande surface vide. Vérifié par
getComputedStyle (max-width devient bien "none", pointer-events et
plein-cadre de la page toujours corrects dans les deux modes).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 11:03:33 +02:00
williamandClaude Sonnet 5 82223171a9 Centre le contenu des mini-jeux plein-page au lieu de le laisser tassé en haut
Le joueur (.docQuizPlayer/.docAssocPlayer/.docMemoryPlayer/
.docMotsPlayer/.docScenarioPlayer) remplissait déjà toute la hauteur
de la page (flex:1) mais sa carte interne restait en flux normal,
collée en haut-gauche — passage du joueur en display:flex + centrage,
avec une largeur confortable (max-width) sur les cartes internes
plutôt qu'un étirement bord à bord. Vérifié par getComputedStyle
(display:flex/centrage du joueur, max-width de la carte, gating
pointer-events et plein-cadre de la page toujours corrects).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 10:58:46 +02:00
williamandClaude Sonnet 5 33d90e896e Les mini-jeux restent visibles en édition, jouables seulement en Aperçu
Le joueur réel (Quiz/Association/Memory/Mots mêlés/Scénario) n'est
plus display:none hors Aperçu — visible en permanence, édition ET
Aperçu, et occupe toute la page dans les deux modes (déjà garanti
depuis la règle plein-cadre :only-child, vérifié inchangé). Seule
l'interactivité (répondre/glisser/retourner une carte) reste réservée
au Mode Aperçu, via pointer-events:none par défaut / auto en Aperçu —
remplace l'ancien display:none/block qui masquait tout hors Aperçu.
Vérifié par getComputedStyle (display/pointer-events dans les deux
modes, padding/border-radius plein-cadre toujours à 0 en Aperçu).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 10:47:21 +02:00
williamandClaude Sonnet 5 1a80cb32b5 Page au format A4 paysage à taille fixe ; un mini-jeu occupe toute la page à lui seul
- .docPage passe à une taille FIXE (960px, ratio A4 paysage 297:210
  via aspect-ratio) au lieu de grandir avec le contenu, et
  overflow:hidden — le contenu qui dépasse ne défile plus, au
  créateur de le répartir sur une autre page (comme une vraie
  diapositive, jamais de reflow automatique).
- Un mini-jeu ne peut plus partager sa page avec un autre élément, ni
  l'inverse : vérifié côté serveur (routes/document/
  document_element_add.py, point d'entrée unique de tout ajout),
  jamais dupliqué côté client qui se contente d'afficher l'erreur
  renvoyée (forgeDocApiAdd). Un mini-jeu ne peut pas non plus rejoindre
  une rangée. 4 nouveaux tests de non-régression.
- CSS : quand un mini-jeu est l'unique enfant de la page
  (.docPageContent > .docMinigame:only-child, invariant garanti par le
  serveur), il s'étire en plein cadre (padding de la page à 0, coins
  non arrondis, joueur en flex:1).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 10:36:10 +02:00
williamandClaude Sonnet 5 60047a3658 Corrige le trait de l'onglet actif : il suivait des coins arrondis au lieu d'être plat
Le <button> générique Bulma (static/style.css) pose
border-radius:var(--bulma-control-radius) sur TOUT bouton, jamais
annulé par notre seule déclaration border-bottom (la cascade CSS
s'applique propriété par propriété, pas règle par règle) : le trait
du bas remontait donc visiblement sur les côtés au lieu de rester
plat. Ajoute border-radius:0 explicite sur .docSidebarTab. Vérifié
via getComputedStyle (border-radius devient bien 0px).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 10:16:50 +02:00
williamandClaude Sonnet 5 2de22ff674 Ajoute un padding au conteneur de la liste de pages
Espace autour des rangées, notamment à droite pour ne pas coller la
barre de défilement fine au texte.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 10:10:22 +02:00
williamandClaude Sonnet 5 47c0f9e85c Barre de défilement fine et discrète pour la liste de pages
scrollbar-width:thin/scrollbar-color (Firefox/Chromium récents) +
::-webkit-scrollbar (WebKit/Blink plus anciens) sur .docPageManagerList
au lieu de la barre large par défaut du navigateur.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 10:03:03 +02:00
williamandClaude Sonnet 5 763d26c1f1 Ajuste l'onglet Pages : bouton d'ajout fixe en haut, retire les flèches de réordonnancement, style d'onglet simplifié
- "+ Ajouter une page" passe avant la liste et reste toujours visible
  (flex-shrink:0), seule la liste défile désormais.
- Retire les boutons ↑/↓ par rangée : le glisser-déposer suffit pour
  réordonner, ce qui laisse plus de place à l'affichage du nom de la
  page. forgeDocMovePage devient mort (plus aucun appelant) et est
  supprimé.
- Style de l'onglet actif simplifié : seul le border-bottom change de
  couleur, le texte reste neutre (plus de changement de couleur du
  libellé).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 09:33:45 +02:00
williamandClaude Sonnet 5 b8e7a4c672 Corrige les onglets du panneau gauche : les deux panneaux restaient visibles en même temps
.docSidebarTabPanel { display:flex; } (règle auteur) gagnait
systématiquement sur le display:none natif de l'attribut [hidden]
(règle du navigateur) — l'origine "auteur" l'emporte toujours sur
l'origine "navigateur" en cascade CSS, peu importe l'ordre des
règles ou leur spécificité. forgeDocSwitchSidebarTab posait bien
l'attribut hidden (vérifié par un test jsdom qui, lui, ne teste que
la propriété DOM .hidden — angle mort qui a laissé passer ce bug),
mais son effet visuel était annulé : les deux onglets ("Pages" et
"Mise en page") s'affichaient empilés en permanence. Ajoute
.docSidebarTabPanel[hidden] { display:none; } pour reprendre la main.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 09:20:03 +02:00
williamandClaude Sonnet 5 b4e4e80b7c Panneau gauche à onglets : "Pages" (gestion complète) et "Mise en page" (bibliothèque)
Build and deploy / test-python (push) Successful in 10m10s
Build and deploy / test-js (push) Successful in 51s
Build and deploy / lint-python (push) Successful in 4m44s
Build and deploy / lint-js (push) Failing after 1m27s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Build and deploy / sonarqube (push) Failing after 4m8s
Remplace le carrousel une-page-à-la-fois par un panneau dédié plein
hauteur : liste verticale scrollable de toutes les pages, réordonnage
par glisser OU boutons haut/bas (accessibilité clavier), renommer
(crayon, édition en ligne), supprimer (protégé contre la suppression
de la dernière page), ajouter. La bibliothèque d'éléments passe dans
un second onglet "Mise en page", contenu inchangé. "Mise en page"
actif par défaut au chargement.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 09:03:21 +02:00
williamandClaude Sonnet 5 14ca9e32a8 Élargit le carré de page pour occuper toute la largeur disponible
Build and deploy / test-python (push) Successful in 5m54s
Build and deploy / test-js (push) Successful in 49s
Build and deploy / lint-python (push) Successful in 3m49s
Build and deploy / lint-js (push) Failing after 1m17s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Build and deploy / sonarqube (push) Failing after 3m43s
.docPageListItem/.docPageListSquare passent en width:100% (le carré
suit via aspect-ratio:1) au lieu d'une taille fixe étroite ; les
flèches ‹/› de navigation s'étirent en hauteur (align-items:stretch)
pour accompagner la carte désormais plus haute.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 15:20:08 +02:00
williamandClaude Sonnet 5 e33b11458c Bande de pages : un seul carré visible (la page active), les flèches changent de page
Build and deploy / test-python (push) Successful in 9m16s
Build and deploy / test-js (push) Successful in 51s
Build and deploy / lint-python (push) Successful in 4m11s
Build and deploy / lint-js (push) Failing after 1m23s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Build and deploy / sonarqube (push) Failing after 4m14s
Remplace le bandeau scrollable multi-cartes par un vrai carrousel
une-page-à-la-fois : un seul carré affiché (la page active), les
grandes flèches ‹/› aux extrémités changent directement la page
affichée (au lieu de juste faire défiler la vue). Les petites flèches
←/→ au-dessus du carré restent pour réordonner la page active parmi
ses sœurs, sans changer l'affichage — distinctes des flèches de
navigation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 15:08:09 +02:00
williamandClaude Sonnet 5 a2b95bd547 Améliore l'UX de la bande de pages : carré + nom en dessous, flèches précédent/suivant, défilement au survol
Build and deploy / test-python (push) Successful in 7m22s
Build and deploy / test-js (push) Successful in 51s
Build and deploy / lint-python (push) Successful in 5m15s
Build and deploy / lint-js (push) Failing after 1m14s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Build and deploy / sonarqube (push) Failing after 5m23s
Chaque carte devient carré (numéro) + nom tronqué SOUS le carré, avec
les actions (réordonner/renommer/supprimer) au-dessus plutôt que sur
le côté. Ajoute deux flèches aux extrémités de la bande pour faire
défiler la VUE (distinct du réordonnancement par carte). Un titre
tronqué défile au survol de la souris (marquee), mesuré dynamiquement
— jamais pour un titre qui tient déjà.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 15:23:19 +02:00
williamandClaude Sonnet 5 8dc4b35dcf Supprime la couche de formes libres, déplace la navigation de page dans le panneau gauche
Build and deploy / test-python (push) Successful in 7m43s
Build and deploy / test-js (push) Successful in 57s
Build and deploy / lint-python (push) Successful in 5m29s
Build and deploy / lint-js (push) Failing after 1m16s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Build and deploy / sonarqube (push) Failing after 5m42s
Formes libres (rectangle/cercle/triangle/trait) retirées de bout en
bout (bibliothèque, rendu, panneau Propriétés, grille d'accroche,
JS/CSS associés) — fonctionnalité non retenue.

La bande de vignettes visuelles des pages au-dessus du canevas est
remplacée par une section "Pages" dans le panneau de gauche (liste
simple : ajouter/renommer/réordonner (haut/bas)/supprimer), à la
place de l'ex-catégorie "Mise en page" de la bibliothèque. La route
document_edit ne rend plus qu'une seule page (celle affichée) au
chargement, au lieu de toutes les pages pour alimenter les anciennes
vignettes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 14:40:33 +02:00
williamandClaude Sonnet 5 57c4de3d8a Remplace les onglets texte de pages par de vraies vignettes miniatures
Build and deploy / test-python (push) Successful in 7m23s
Build and deploy / test-js (push) Successful in 1m24s
Build and deploy / lint-python (push) Successful in 6m29s
Build and deploy / lint-js (push) Failing after 1m31s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Build and deploy / sonarqube (push) Failing after 5m18s
Les vignettes réutilisent le HTML réellement rendu de chaque page
(CSS scale trick) et sont centrées dans le conteneur du milieu, au
lieu d'une barre pleine largeur.

Corrige au passage deux bugs réels trouvés en écrivant les tests :
- une page contenant un mini-jeu (bouton Suivant/Recommencer) cassait
  le parsing HTML car .docPageThumbCard était un <button> englobant
  un autre <button> ; passage en div role="button" + équivalent
  clavier, contenu copié rendu inert.
- le renommage d'une page par double-clic ne fonctionnait plus du
  tout (sélecteur .docPageTab oublié lors du renommage des classes).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 13:50:05 +02:00
williamandClaude Sonnet 5 ecd483f352 Implemente un systeme de pages pour le support de formation
Build and deploy / test-python (push) Successful in 7m48s
Build and deploy / test-js (push) Successful in 52s
Build and deploy / lint-python (push) Successful in 5m44s
Build and deploy / lint-js (push) Failing after 1m52s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Build and deploy / sonarqube (push) Failing after 5m5s
Retour utilisateur : "il faut implementer un systeme de page". Un
support est desormais compose de PLUSIEURS pages (_document_pages),
chacune un document independant affiche seul sur le canevas -- chaque
element appartient a exactement une page via page_id (document_engine/
elements/*, routes/document/document_element_add.py/document_render.py
revalident desormais un page_id explicite). Migration automatique et
silencieuse pour les supports crees avant cette fonctionnalite
(db/supports/ensure_document_pages_schema.py, meme convention que les
ensure_X_schema.py existants) : leurs elements deviennent tous les
enfants d'une "Page 1" creee a la volee, aucune perte de contenu.

Nouveau paquet document_engine/pages/ (add/list/get/rename/move/delete)
et 4 routes dediees (routes/document/document_page_*.py) -- supprimer
la DERNIERE page restante est refuse (garde-fou pose a la route, meme
decoupage que routes/game/screens/screen_delete.py cote jeu, jamais
dans la fonction bas niveau).

Cote editeur : une bande d'ONGLETS au-dessus du canevas (jamais un
panneau lateral, choix explicite de l'utilisateur) -- clic pour changer
de page, double-clic pour renommer (contenteditable), glisser pour
reordonner, "+" pour ajouter, "x" pour supprimer. Changer de page vide
la pile Annuler/Retablir (une commande empilee sur une autre page n'a
plus de sens). Mode Apercu : navigation Page precedente/suivante avec
indicateur "Page X / N" (choix explicite : page par page, pas de
defilement continu), jamais affichee s'il n'y a qu'une seule page.

Verifie : suite pytest complete (702 tests, dont 14 nouveaux pour les
routes de pages), simulation DOM reelle (jsdom, 25 assertions couvrant
tout le cycle de vie cote client -- creation/bascule/renommage/
reordonnancement/suppression de page, portee correcte des elements par
page, pile Annuler/Retablir videe au changement de page, pilule de
navigation en Apercu), et un test de fumee HTTP reel contre le serveur
de dev en marche (creation/ajout d'element/rendu/renommage/suppression
d'une page, refus de supprimer la derniere page, page inconnue -> 404).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 13:24:23 +02:00
williamandClaude Sonnet 5 ecea430ee2 Terminologie coherente dans le graphe du Scenario : Situation, pas Nœud
Build and deploy / test-python (push) Successful in 9m47s
Build and deploy / test-js (push) Successful in 53s
Build and deploy / lint-python (push) Successful in 5m44s
Build and deploy / lint-js (push) Failing after 1m31s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Build and deploy / sonarqube (push) Failing after 5m0s
Retour utilisateur : "il y a situation => choix => le choix devient la
situation => avec des choix ... il faut bien choisir les termes". Le
vocabulaire technique de graphe ("Nœud N", "Départ") ne correspond pas
au modele mental du createur : chaque point du graphe EST une
situation (initiale, ou atteinte via un choix precedent), qui a ses
propres choix. Renomme dans toute l'UI (labels des cartes, bouton
d'ajout, indices de la modale de connexion, etiquette de destination,
etat vide de l'inspecteur, texte par defaut d'une nouvelle situation) -
aucun changement de la structure de donnees (x/y/id/text/choices reste
identique), uniquement la terminologie affichee.

Verifie via simulation DOM reelle avec le VRAI sanitize_scenario_config
Python (jamais une reimplementation JS approximative) que l'ajout
d'une situation et l'ajout d'un choix fonctionnent bien de bout en
bout : la fonctionnalite marchait deja (la modale transparente du
commit precedent explique tres probablement le "ca ne marche pas" -
aucun retour visuel rendait les clics invisibles).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 11:57:49 +02:00
williamandClaude Sonnet 5 0babad5018 Corrige la modale d'arbre du Scenario transparente
Build and deploy / test-python (push) Successful in 6m59s
Build and deploy / test-js (push) Successful in 45s
Build and deploy / lint-python (push) Successful in 5m8s
Build and deploy / lint-js (push) Failing after 1m45s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Build and deploy / sonarqube (push) Failing after 4m20s
Retour utilisateur : "la modale est transparente". Cause : les tokens
--doc-* (couleurs de fond/bordure/texte de l'editeur) sont definis
uniquement sur .docEditor3 ; #docScenarioTreeModal est volontairement
un FRERE de .docEditor3 dans le HTML (jamais un descendant, sinon
position:fixed serait rogne par le overflow:hidden de main.content --
meme bug deja trouve le 20/09/2026 pour le bandeau d'outils), donc ne
les heritait jamais -- var(--doc-bg-2) etc. retombaient sur transparent
partout dans la modale (fond du dialogue, mais aussi toutes les
couleurs d'accent/bordures/succes du graphe visuel).

Duplique la definition des tokens --doc-* sur #docScenarioTreeModal
(meme valeurs, meme variante [data-theme="light"]) plutot que de
deplacer la modale dans le DOM. forgeDocApplyTheme pose desormais le
meme data-theme sur les deux elements pour qu'ils restent synchronises.

Verifie via simulation DOM reelle (jsdom) : les deux elements recoivent
bien le meme data-theme apres un changement de theme.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 09:35:54 +02:00
williamandClaude Sonnet 5 d92f75a803 Remplace la modale liste+menus deroulants du Scenario par un vrai graphe visuel
Build and deploy / test-python (push) Successful in 7m32s
Build and deploy / test-js (push) Successful in 49s
Build and deploy / lint-python (push) Successful in 5m21s
Build and deploy / lint-js (push) Failing after 1m12s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Build and deploy / sonarqube (push) Failing after 3m53s
Retour utilisateur : "passons a un veritable graphe visuel". Chaque
nœud gagne une position x/y (document_engine/labels/scenario_config.py
: un x/y manquant/invalide retombe sur un quadrillage en cascade derive
de l'index du nœud, jamais (0, 0) pour tous les nœuds qui les
empilerait au meme endroit).

Cote editeur (static/document/js/document-editor.js) : les nœuds sont
des cartes deplacables a la souris sur un canevas (glisser-deposer reel,
meme principe que le glisser des formes libres), les choix relies a une
cible sont dessines comme des fleches SVG etiquetees par leur texte
(jamais un menu deroulant). Editer le texte/les choix d'un nœud se fait
dans un panneau inspecteur (colonne de droite) pour le nœud
selectionne ; relier un choix se fait en cliquant "Relier" puis le nœud
cible sur le graphe (mode connexion, Echap annule sans fermer la
modale). La modale generique (.docModal*) est agrandie specifiquement
pour ce graphe (jusqu'a 1180px) sans toucher sa taille par defaut.

Verifie via simulation DOM reelle (jsdom) : rendu des nœuds/positions,
glisser-deposer avec persistance au relachement, traces des fleches SVG
+ etiquettes, workflow complet du mode connexion, suppression d'un nœud
avec reparation des references pendantes, et les 3 façons de fermer la
modale (bouton/fond/Echap) y compris l'annulation du mode connexion
sans fermer.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 09:03:47 +02:00
williamandClaude Sonnet 5 7e504b7865 Sanitize les attributs en LECTURE aussi, pas seulement a l'ecriture
Build and deploy / test-python (push) Successful in 11m5s
Build and deploy / test-js (push) Successful in 55s
Build and deploy / lint-python (push) Successful in 6m29s
Build and deploy / lint-js (push) Failing after 1m39s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Build and deploy / sonarqube (push) Failing after 5m18s
Bug reel constate le 21/09/2026 : un element Scenario cree avant la
refonte en arbre de decision (ancien schema plat "situation"/"choices"/
"correct_index") faisait planter silencieusement le panneau Proprietes
cote client des la selection - scenario.nodes etait inexistant sur
l'ancienne forme, aucune erreur visible, juste "il ne se passe rien".
Cause : document_edit.py et document_render.py renvoyaient les
attributs BRUTS de la base au client, jamais revalides - contrairement
a la route d'ecriture qui, elle, sanitize deja avant de persister.

Ajoute document_engine.sanitize_element_attributes(kind, attributes),
point d'entree unique de dispatch kind -> sanitize_X_config, utilise
desormais a la fois en ecriture (document_element_update.py, qui
reutilise ce nouveau dispatch au lieu de son if/elif duplique) ET en
lecture (document_edit.py/document_render.py). Elimine toute la classe
de bug "schema devenu obsolete apres une evolution du modele de
donnees d'un mini-jeu, donnee jamais retouchee depuis" - present et
futur, pas seulement pour Scenario.

Migre les donnees reelles deja affectees (support de test, element 46)
vers le nouveau schema en arbre, en preservant integralement le
contenu deja redige par l'utilisateur (situation + 3 choix/consequences
du scenario "chat sur la route").

Ajoute un test de non-regression qui ecrit delibirement l'ancien schema
en base (en contournant le sanitize de la route d'ecriture, pour
simuler une donnee reellement ancienne jamais nettoyee) puis verifie
que /edit et /render renvoient une structure saine au client.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 08:07:06 +02:00
williamandClaude Sonnet 5 a529857379 Refonte du Scenario en arbre de decision (modale dediee, plus de vrai/faux
Build and deploy / test-python (push) Failing after 1m15s
Build and deploy / test-js (push) Successful in 54s
Build and deploy / lint-python (push) Failing after 1m12s
Build and deploy / lint-js (push) Failing after 1m8s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Build and deploy / sonarqube (push) Failing after 1m4s
Retour utilisateur : "une consequence peut mener a d'autres choix et
ainsi de suite, il n'y a pas de notion vrai/faux, il faut une modale
avec la possibilite de construire un veritable arbre de choix
consequence". Remplace le modele plat (situation + 2-4 choix + un seul
bon choix + une consequence terminale) par un vrai graphe de nœuds :
{"title", "nodes": [{"id", "text", "choices": [{"text", "target_id"}]}]}
- nodes[0] est la situation initiale, chaque choix peut pointer vers
n'importe quel autre nœud (branchement, convergence, fins multiples),
un nœud sans choix est une fin de branche valide. Aucune notion de
bonne/mauvaise reponse.

L'arbre se construit desormais dans une modale dediee (trop de
structure pour la colonne etroite du panneau Proprietes) : liste de
nœuds, chaque choix avec un menu deroulant "mene a" listant les autres
nœuds ou "fin de branche". La modale est un composant generique
(.docModal*) independant de tout framework externe.

sanitize_scenario_config degrade silencieusement tout target_id
orphelin (nœud supprime) vers None plutot que de faire echouer le
scenario entier. Le lecteur cote client navigue le graphe nœud par
nœud, le texte du nœud visite remplace le precedent (toujours pas un
Quiz), jusqu'a une fin de branche puis passage au scenario suivant.

Verifie via simulation DOM reelle (jsdom) : navigation ramifiee
(branchement, convergence, fin via nœud vide ET via choix sans cible),
plusieurs arbres a la suite, et l'editeur modal complet (ouverture,
ajout/suppression de nœud avec reparation des references pendantes,
changement de cible, fermeture bouton/fond/Echap).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 17:29:24 +02:00
williamandClaude Sonnet 5 c04a81cfec La consequence du Scenario remplace la situation, pas un feedback Quiz
Build and deploy / test-python (push) Failing after 1m2s
Build and deploy / test-js (push) Successful in 51s
Build and deploy / lint-python (push) Failing after 58s
Build and deploy / lint-js (push) Failing after 51s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Build and deploy / sonarqube (push) Failing after 1m1s
Retour utilisateur : "ce n'est pas un quiz, la consequence s'affiche a
la place de la situation precedente". L'ancien design affichait la
situation ET les choix en permanence avec un encart de feedback separe
en dessous (calque sur le Quiz). Desormais un seul bloc de texte
(.docScenarioSituation) sert successivement a la situation PUIS, une
fois un choix fait, a la consequence a sa place ; les boutons de choix
disparaissent avec elle. Supprime l'element .docScenarioConsequence
devenu inutile.

Verifie via simulation DOM reelle (jsdom) : la consequence remplace bien
le texte de la situation (jamais affichee a cote), les choix
disparaissent, le retour a une situation neutre au scenario suivant/au
redemarrage fonctionne.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 17:10:56 +02:00
williamandClaude Sonnet 5 b2292de122 Implemente le mini-jeu Scenario (situation, choix, consequences)
Build and deploy / test-python (push) Failing after 59s
Build and deploy / test-js (push) Successful in 50s
Build and deploy / lint-python (push) Failing after 59s
Build and deploy / lint-js (push) Failing after 1m1s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Build and deploy / sonarqube (push) Failing after 59s
Cinquieme mini-jeu du support de formation : le createur ecrit une
situation initiale, definit 2 a 4 choix, indique lequel est le bon, et
redige la consequence de chaque choix. Plusieurs scenarios peuvent etre
crees, joues dans l'ordre d'ecriture (jamais melanges, contrairement a
Association/Memory/Mots meles : ce sont des mises en situation
sequentielles). En Apercu, l'apprenant lit la situation, choisit une
option, decouvre la consequence de SON choix et si c'etait le bon, puis
passe au scenario suivant. Comme Association/Memory/Mots meles, le
dernier scenario reste affiche une fois repondu : seul le bouton
Recommencer apparait, jamais un ecran de resultat separe (reserve au
Quiz). Reutilise les classes visuelles du Quiz (docQuizOptions/
docQuizFeedback/docQuizNextBar) plutot que de dupliquer ces regles.

Verifie via simulation DOM reelle (jsdom) : progression entre plusieurs
scenarios, choix correct/incorrect avec revelation de la bonne reponse,
comportement de fin de partie, panneau Proprietes (ajout/suppression de
scenario, changement du nombre de choix, selection du bon choix).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 16:50:10 +02:00
williamandClaude Sonnet 5 d8be80ebd1 Implemente le mini-jeu Mots meles (grille reelle, placement 4 directions)
Build and deploy / test-python (push) Failing after 1m0s
Build and deploy / test-js (push) Failing after 50s
Build and deploy / lint-python (push) Failing after 1m2s
Build and deploy / lint-js (push) Failing after 1m7s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Build and deploy / sonarqube (push) Failing after 1m1s
Quatrieme mini-jeu du support de formation : le createur ecrit 5 a 10
mots (recommandation souple, comme MIN_PAIRS/MIN_CARDS), places par le
serveur dans une grille carree horizontalement, verticalement, ou en
diagonale (deux sens seulement, jamais a l'envers) via un vrai
algorithme de placement avec retry/agrandissement de grille en cas de
conflit. La grille ET la position exacte de chaque mot sont calculees
cote serveur puis embarquees en JSON ; le client valide chaque
selection (glisser ou cliquer-cliquer) par comparaison de coordonnees
exactes, jamais une simple comparaison de texte (qui se tromperait sur
des lettres partagees entre deux mots qui se croisent). Comme
Association/Memory, la grille reste affichee une fois tous les mots
trouves : seul le bouton Recommencer (.docMinigameRestartBar, partage)
apparait.

Verifie via simulation DOM reelle (jsdom) : selection au glisser ET au
clic-clic, mot invalide sans crash, barre de fin qui bascule, panneau
Proprietes (ajout/suppression de mot avec revalidation serveur).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 15:31:46 +02:00
williamandClaude Sonnet 5 1b1307bb85 Association et Memory restent affiches une fois termines, seul Recommencer bascule
Build and deploy / test-python (push) Failing after 59s
Build and deploy / test-js (push) Failing after 52s
Build and deploy / lint-python (push) Failing after 59s
Build and deploy / lint-js (push) Failing after 1m1s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Build and deploy / sonarqube (push) Failing after 1m4s
Les deux mini-jeux gardaient jusqu'ici un ecran de resultat separe qui
remplacait le plateau de jeu (comme le Quiz). Le plateau reste desormais
visible en permanence ; seule une barre partagee .docMinigameRestartBar
apparait/disparait. Le Quiz garde son propre comportement (ecran de
resultat separe), inchange.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 14:43:26 +02:00
williamandClaude Sonnet 5 a34a95a3b4 Une paire de Memory trouvee reste clairement face retournee
Build and deploy / test-python (push) Failing after 51s
Build and deploy / test-js (push) Successful in 48s
Build and deploy / lint-python (push) Failing after 1m3s
Build and deploy / lint-js (push) Failing after 1m2s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Build and deploy / sonarqube (push) Failing after 1m2s
Retour utilisateur direct. La classe is-flipped n'etait deja jamais
retiree pour une carte appariee (forgeDocMemoryFlip) - elle restait
donc techniquement retournee - mais l'opacite reduite (55%) posee sur
.docMemoryCard.is-matched, a cote de cartes face cachee pleinement
opaques, se lisait visuellement comme "repartie face cachee". Retire
cette opacite et renforce l'etat "trouve" avec un fond/une bordure
verts sur la seule face visible (le verso), sans jamais assombrir le
contenu revele.

ruff/mypy --strict/stylelint tous verts (changement CSS pur, aucune
logique touchee) ; 62 tests document verifies frais.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 14:23:38 +02:00
williamandClaude Sonnet 5 4111081e1a Implemente le mini-jeu Memory (retournement de cartes, mode paire/single)
document_engine/labels/memory_config.py (nouveau) : modele de donnees,
meme convention resolve_X/sanitize_X que quiz_config.py/
association_config.py - DEFAULT_MEMORY_CONFIG, sanitize_memory_config.
Chaque carte a un recto ET un verso, chacun avec image/texte
independants et tous deux optionnels ; seul le verso doit avoir au
moins l'un des deux non vide (rien a reveler/apparier sinon) - le
recto peut rester entierement vide (dos de carte generique "?" par
defaut). Liste tronquee a MAX_CARDS=8.

Cote serveur, routes/document/document_element_update.py revalide
desormais aussi memory avant persistance. Le rendu (_render_memory)
affiche un resume reel (nombre de cartes, mode) et, des qu'au moins
une carte existe, un plateau de retournement REELEMENT interactif en
Mode Apercu (_render_memory_player) : en mode "paire", chaque carte
definie est DUPLIQUEE en deux instances partageant le meme card_index
(appariement classique) ; en mode "single", une seule instance par
carte (simple retournement, sans appariement - "c'est donc un
retourner de carte classique plus un jeu memory"). Les instances sont
melangees (random.shuffle, documente dans CODE_QUALITY.md) puis
embarquees en JSON dans data-memory-config.

Cote editeur, le panneau Proprietes propose un bascule segmentee
Paire/Simple et une liste de cartes repetable, chaque carte avec ses
deux faces (recto/verso) editables independamment (image + texte).
Extrait au passage forgeDocEscapeHtml (ex-forgeDocEscapeForTextarea,
generalise pour couvrir aussi les attributs) reutilise pour les deux
mini-jeux. Le plateau jouable (static/document/js/document-editor.js)
est une vraie carte-retournement CSS 3D (perspective/rotateY), contenu
de chaque face construit via DOM (textContent/img.src, jamais
innerHTML avec le texte du createur - meme precaution que le plateau
Association) : bon appariement verrouille en vert, mauvais reinitialise
apres un delai, ecran de resultat une fois le jeu termine (les deux
modes), et un "Recommencer" qui remelange reellement les cartes
(Fisher-Yates cote client).

Tests : 11 tests purs (tests/document/test_memory_config.py, sans
Flask, dont un qui verifie explicitement la duplication en mode paire
vs son absence en mode single) + 1 test de route verifiant la
sanitization a l'ecriture.

SKIP=djlint : backlog H021 pre-existant, aucun template touche ici.
ruff/mypy --strict/vulture/bandit/import-linter/eslint/stylelint tous
verts ; 62 tests document verifies frais. Verification manuelle live
complete : ajout, sanitization sur carte invalide, rendu du plateau,
et simulation DOM du gameplay reel dans les DEUX modes (mode paire :
mauvais appariement puis bon appariement puis jeu complet ; mode
single : retournement puis jeu complet) - script de diagnostic non
conserve dans le depot.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 14:13:28 +02:00
80 changed files with 11695 additions and 537 deletions
+3 -2
View File
@@ -129,7 +129,7 @@ SonarQube : voir section 2, sous-section "SonarQube" — CI restaurée (non-bloq
| Site(s) | Outil / règle | Raison | Contexte | | Site(s) | Outil / règle | Raison | Contexte |
|---|---|---|---| |---|---|---|---|
| `core/flask_app.py:22` | `python:S4502` (Sonar) | CSRF géré par `core/csrf_guard.py` — garde maison globale (`@app.before_request`), testée dans `test_csrf.py`, jamais Flask-WTF. Sonar ne reconnaît pas cette implémentation custom. | Phase 3 | | `core/flask_app.py:22` | `python:S4502` (Sonar) | CSRF géré par `core/csrf_guard.py` — garde maison globale (`@app.before_request`), testée dans `test_csrf.py`, jamais Flask-WTF. Sonar ne reconnaît pas cette implémentation custom. | Phase 3 |
| `game_engine/data_actions/compute_operation.py` (×2), `static/game/js/play/offline/compute-operation.js` (×2), `static/game/js/scenes/collision-rules-editor.js` (×2), `static/game/js/triggers/trigger-editor.js`, `document_engine/rendering/render_document_element.py` (×2, `_render_association_player` — ajouté le 20/09/2026) | `B311`/`S311`/`python:S2245`/`javascript:S2245` | Tirage aléatoire de jeu (dé, id local d'UI, mélange des deux colonnes du mini-jeu Association) — jamais un usage cryptographique. | Phase 3 ; complété le 20/09/2026 | | `game_engine/data_actions/compute_operation.py` (×2), `static/game/js/play/offline/compute-operation.js` (×2), `static/game/js/scenes/collision-rules-editor.js` (×2), `static/game/js/triggers/trigger-editor.js`, `document_engine/rendering/render_document_element.py` (×7 : `_render_association_player` ×2, `_render_memory_player` ×1, `_mots_place_word` ×3, `_mots_attempt_placement` ×1 — les 3 derniers ajoutés le 20/09/2026 pour le mini-jeu Mots mêlés) | `B311`/`S311`/`python:S2245`/`javascript:S2245` | Tirage aléatoire de jeu (dé, id local d'UI, mélange des deux colonnes du mini-jeu Association, mélange des cartes du mini-jeu Memory, direction/position de placement + lettre de remplissage de la grille du mini-jeu Mots mêlés) — jamais un usage cryptographique. | Phase 3 ; complété le 20/09/2026 |
| `static/game/js/play/offline/xapi-client.js` (18 sites) + `static/game/js/play/offline/__tests__/xapi-client.test.js` (2 sites) | `javascript:S5332` | Identifiants du vocabulaire xAPI standard ADL (`http://adlnet.gov/expapi/...`), jamais déréférencés en réseau — simples chaînes comparées/embarquées, le `http://` fait partie du texte fixé par la spec. Le vrai endpoint réseau (`config.endpoint`) est toujours saisi par le créateur, jamais un littéral de ce fichier. | Phase 3 | | `static/game/js/play/offline/xapi-client.js` (18 sites) + `static/game/js/play/offline/__tests__/xapi-client.test.js` (2 sites) | `javascript:S5332` | Identifiants du vocabulaire xAPI standard ADL (`http://adlnet.gov/expapi/...`), jamais déréférencés en réseau — simples chaînes comparées/embarquées, le `http://` fait partie du texte fixé par la spec. Le vrai endpoint réseau (`config.endpoint`) est toujours saisi par le créateur, jamais un littéral de ce fichier. | Phase 3 |
| `publish/scorm_manifest.py` | `B406` (Bandit) | Seul fichier du dépôt qui touche du XML — uniquement en génération (`xml.sax.saxutils.escape`), jamais en parsing d'XML externe. | Phase 3 | | `publish/scorm_manifest.py` | `B406` (Bandit) | Seul fichier du dépôt qui touche du XML — uniquement en génération (`xml.sax.saxutils.escape`), jamais en parsing d'XML externe. | Phase 3 |
| `scripts/game/build_demo_dialogues.py:61-63` | `python:S8371` (Sonar) | Accès direct `resp.headers["Location"]` volontaire : script d'usage unique jamais exécuté en production, un `KeyError` cru est un échec au moins aussi clair qu'un `.get()` renvoyant `None`. | Phase 3 | | `scripts/game/build_demo_dialogues.py:61-63` | `python:S8371` (Sonar) | Accès direct `resp.headers["Location"]` volontaire : script d'usage unique jamais exécuté en production, un `KeyError` cru est un échec au moins aussi clair qu'un `.get()` renvoyant `None`. | Phase 3 |
@@ -141,7 +141,7 @@ SonarQube : voir section 2, sous-section "SonarQube" — CI restaurée (non-bloq
| `static/game/js/scenes/scene-editor.js:214` (`CURRENT_SELECTED_ID = null`), `static/game/js/screen_edit/tree-panels.js:392` (`CURRENT_SELECTED_ID = selectedId \|\| null`) | `javascript:S2703` | Déclarée en **`var`** (pas `let`/`const`) dans `templates/game/scene_edit.html:833` (`var CURRENT_SELECTED_ID = {{ selected_id or 'null' }};`), chargé avant `tree-panels.js`/`scene-editor.js`/`trigger-editor.js`/`collision-rules-editor.js` (ordre vérifié, lignes 833/858/880/885/887/888) — un `var` de script classique attache directement à `window`, réassignable sans aucune restriction depuis n'importe quel autre `<script>` de la page (contrairement au cas `SCENE_OBJECT_NAMES` ci-dessous, qui lui était en `const`). Déjà documenté dans `.eslintrc.json` (`"CURRENT_SELECTED_ID": "writable"`). | Lot 2 "modernisation JS", 16/09/2026 | | `static/game/js/scenes/scene-editor.js:214` (`CURRENT_SELECTED_ID = null`), `static/game/js/screen_edit/tree-panels.js:392` (`CURRENT_SELECTED_ID = selectedId \|\| null`) | `javascript:S2703` | Déclarée en **`var`** (pas `let`/`const`) dans `templates/game/scene_edit.html:833` (`var CURRENT_SELECTED_ID = {{ selected_id or 'null' }};`), chargé avant `tree-panels.js`/`scene-editor.js`/`trigger-editor.js`/`collision-rules-editor.js` (ordre vérifié, lignes 833/858/880/885/887/888) — un `var` de script classique attache directement à `window`, réassignable sans aucune restriction depuis n'importe quel autre `<script>` de la page (contrairement au cas `SCENE_OBJECT_NAMES` ci-dessous, qui lui était en `const`). Déjà documenté dans `.eslintrc.json` (`"CURRENT_SELECTED_ID": "writable"`). | Lot 2 "modernisation JS", 16/09/2026 |
| `static/game/js/scenes/collision-rules-editor.js:78` (`let _collisionWizard = null;`) et ses réassignations dans ce fichier, **et** `static/game/js/triggers/trigger-editor.js:787,937` (`triggerOpenAppendActionModal`/`screenTriggerOpenAppendActionModal`, `_collisionWizard = { bodyEl: body };` sans mot-clé) | `javascript:S2703` | Même pattern de partage inter-scripts que `SCENE_OBJECT_NAMES` : `trigger-editor.js` réutilise TELLES QUELLES les étapes de l'assistant de `collision-rules-editor.js` (`renderCollisionWizardChainStep`/`collisionWizardBuildLeafAction`, qui ne lisent que `.bodyEl` — voir commentaire ligne 784-786 de `trigger-editor.js`) pour poser "+ Ajouter une action" sur un déclencheur déjà existant, plutôt que de dupliquer ces étapes. `trigger-editor.js` est chargé AVANT `collision-rules-editor.js` (`templates/game/scene_edit.html:887-888`), mais sans risque de TDZ : les deux réassignations de `trigger-editor.js` sont à l'intérieur de fonctions déclenchées par un clic utilisateur, jamais exécutées avant que `collision-rules-editor.js` (et son `let _collisionWizard = null;`) n'ait fini de se charger. Déjà documenté dans `.eslintrc.json` (`"_collisionWizard": "writable"`). | Lot 2 "modernisation JS", 16/09/2026 ; complété lot 4, 18/09/2026 | | `static/game/js/scenes/collision-rules-editor.js:78` (`let _collisionWizard = null;`) et ses réassignations dans ce fichier, **et** `static/game/js/triggers/trigger-editor.js:787,937` (`triggerOpenAppendActionModal`/`screenTriggerOpenAppendActionModal`, `_collisionWizard = { bodyEl: body };` sans mot-clé) | `javascript:S2703` | Même pattern de partage inter-scripts que `SCENE_OBJECT_NAMES` : `trigger-editor.js` réutilise TELLES QUELLES les étapes de l'assistant de `collision-rules-editor.js` (`renderCollisionWizardChainStep`/`collisionWizardBuildLeafAction`, qui ne lisent que `.bodyEl` — voir commentaire ligne 784-786 de `trigger-editor.js`) pour poser "+ Ajouter une action" sur un déclencheur déjà existant, plutôt que de dupliquer ces étapes. `trigger-editor.js` est chargé AVANT `collision-rules-editor.js` (`templates/game/scene_edit.html:887-888`), mais sans risque de TDZ : les deux réassignations de `trigger-editor.js` sont à l'intérieur de fonctions déclenchées par un clic utilisateur, jamais exécutées avant que `collision-rules-editor.js` (et son `let _collisionWizard = null;`) n'ait fini de se charger. Déjà documenté dans `.eslintrc.json` (`"_collisionWizard": "writable"`). | Lot 2 "modernisation JS", 16/09/2026 ; complété lot 4, 18/09/2026 |
| `static/game/js/triggers/trigger-editor.js:28` (`let SCENE_OBJECT_NAMES = ...`) | `javascript:S2703` | **Bug réel trouvé et corrigé** (pas un faux positif comme les 4 sites ci-dessus) : était déclarée en `const`, alors que `refreshSceneObjectNames()` (`static/game/js/scenes/scene-editor.js:754-759`) la réassigne après un fetch — deux `<script>` classiques sur la même page partagent un même environnement lexical global, mais une liaison `const` posée dans l'un ne peut pas être réassignée depuis l'autre (`TypeError: Assignment to constant variable.`, reproduit empiriquement via `node:vm`). Symptôme : renommer un personnage puis ouvrir un dialogue de déclencheur sans recharger la page ne montrait jamais le nouveau nom dans "qui parle". Corrigé en `let`, couvert par un test de non-régression (`static/game/js/scenes/__tests__/collision-rules-editor.test.js`, test `refreshSceneObjectNames`) qui échoue avec `TypeError` sur l'ancien code et passe avec le nouveau. | Lot 2 "modernisation JS", 16/09/2026 | | `static/game/js/triggers/trigger-editor.js:28` (`let SCENE_OBJECT_NAMES = ...`) | `javascript:S2703` | **Bug réel trouvé et corrigé** (pas un faux positif comme les 4 sites ci-dessus) : était déclarée en `const`, alors que `refreshSceneObjectNames()` (`static/game/js/scenes/scene-editor.js:754-759`) la réassigne après un fetch — deux `<script>` classiques sur la même page partagent un même environnement lexical global, mais une liaison `const` posée dans l'un ne peut pas être réassignée depuis l'autre (`TypeError: Assignment to constant variable.`, reproduit empiriquement via `node:vm`). Symptôme : renommer un personnage puis ouvrir un dialogue de déclencheur sans recharger la page ne montrait jamais le nouveau nom dans "qui parle". Corrigé en `let`, couvert par un test de non-régression (`static/game/js/scenes/__tests__/collision-rules-editor.test.js`, test `refreshSceneObjectNames`) qui échoue avec `TypeError` sur l'ancien code et passe avec le nouveau. | Lot 2 "modernisation JS", 16/09/2026 |
| 32 sites `\|safe` (`templates/game/scene_edit.html`, `templates/game/play.html`, `templates/auth/register_2fa.html`, `templates/onboarding/onboarding_new.html`, `templates/game_dashboard_simple.html`, `templates/document/document_edit.html`) | `Web:S5247` (Sonar) | **Faux positif confirmé sur le fond, mais non-supprimable techniquement pour l'instant.** 3 sous-groupes : (1) `rendered_html`/`qr_svg`/`description` — HTML déjà échappé côté Python (`html.escape()`) ou généré sans texte libre utilisateur ; (2) 25 sites `*_json` — `db.json_for_script()` échappe déjà `</script>` (voir Phase 3) ; (3) `rendered_document` (`document_edit.html`, ajouté le 20/09/2026) — même sous-groupe (1) : produit par `document_engine.render_document`, qui échappe (`html.escape()`) tout contenu utilisateur avant interpolation (voir `document_engine/rendering/render_document_element.py`). Plusieurs syntaxes de suppression testées (commentaire Jinja `{# #}`, commentaire JS natif dans un `<script>`, bonne position de ligne) : **aucune ne fonctionne** avec l'analyseur Web de cette version de SonarQube. La résolution "Faux positif" via l'API est bloquée par le système de permissions de session. `sonar.issue.ignore.multicriteria` existe mais sans sélecteur de ligne (exclusion fichier entier uniquement) — écarté pour `scene_edit.html`/`play.html` (masquerait un futur `\|safe` réellement dangereux). Ces 32 sites restent visibles dans le rapport Sonar en l'état ; traités et compris, pas un point ouvert côté code. | Phase 3 + investigation du 16/09/2026 ; complété le 20/09/2026 | | 31 sites `\|safe` (`templates/game/scene_edit.html`, `templates/game/play.html`, `templates/auth/register_2fa.html`, `templates/onboarding/onboarding_new.html`, `templates/game_dashboard_simple.html`, `templates/document/document_edit.html`) | `Web:S5247` (Sonar) | **Faux positif confirmé sur le fond, mais non-supprimable techniquement pour l'instant.** 3 sous-groupes : (1) `rendered_html`/`qr_svg`/`description` — HTML déjà échappé côté Python (`html.escape()`) ou généré sans texte libre utilisateur ; (2) 25 sites `*_json` — `db.json_for_script()` échappe déjà `</script>` (voir Phase 3) ; (3) `rendered_document` (`document_edit.html`) — même sous-groupe (1) : produit par `document_engine.render_document`, qui échappe (`html.escape()`) tout contenu utilisateur avant interpolation (voir `document_engine/rendering/render_document_element.py`). Plusieurs syntaxes de suppression testées (commentaire Jinja `{# #}`, commentaire JS natif dans un `<script>`, bonne position de ligne) : **aucune ne fonctionne** avec l'analyseur Web de cette version de SonarQube. La résolution "Faux positif" via l'API est bloquée par le système de permissions de session. `sonar.issue.ignore.multicriteria` existe mais sans sélecteur de ligne (exclusion fichier entier uniquement) — écarté pour `scene_edit.html`/`play.html` (masquerait un futur `\|safe` réellement dangereux). Ces sites restent visibles dans le rapport Sonar en l'état ; traités et compris, pas un point ouvert côté code. | Phase 3 + investigation du 16/09/2026 ; complété le 20/09/2026 et le 21/09/2026 |
| `static/game/js/play/offline/filter-repeater-rows.js:10,11`, `static/game/js/screen_edit/panel-init.js:254,261`, `static/game/js/play/offline/xapi-client.js:132` | `javascript:S8786` (ReDoS) | 3 regex distinctes (2 dupliquées dans 2 fichiers) testées empiriquement, aucune ne montre de backtracking super-linéaire réel — voir le détail complet juste en dessous du tableau (méthode reproductible). | Lot 1 "modernisation JS", 16/09/2026 | | `static/game/js/play/offline/filter-repeater-rows.js:10,11`, `static/game/js/screen_edit/panel-init.js:254,261`, `static/game/js/play/offline/xapi-client.js:132` | `javascript:S8786` (ReDoS) | 3 regex distinctes (2 dupliquées dans 2 fichiers) testées empiriquement, aucune ne montre de backtracking super-linéaire réel — voir le détail complet juste en dessous du tableau (méthode reproductible). | Lot 1 "modernisation JS", 16/09/2026 |
| `static/game/js/play/dialogue-box-controller.js` (`forgeShowQuizBox`, ligne du `void widget.offsetWidth;`) | `javascript:S3735` | Force une lecture de mise en page (reflow) AVANT de reposer la classe `is-active`, pour que l'animation CSS d'entrée du quiz rejoue même si la boîte était déjà active juste avant (2 questions à la suite) — idiome JS standard, `void` marque explicitement une expression dont seul l'EFFET DE LECTURE compte, jamais la valeur. 3 formes essayées dans l'ordre, chacune rejetée par une règle Sonar différente : `void widget.offsetWidth;` (S3735, "retirer void") → `widget.offsetWidth;` seule (S905, "expression sans effet — accepté par ESLint ici, `no-unused-expressions` est désactivé dans ce projet, mais pas par Sonar") → `const _ = widget.offsetWidth;` (S1481, "variable jamais lue — accepté par ESLint via `varsIgnorePattern: ^_$`, pas par Sonar"). Aucune forme ne satisfait Sonar sans en recréer une autre : `void` restauré (la plus lisible/idiomatique des 3, et la seule aussi acceptée par ESLint) et documenté ici plutôt que de continuer à faire tourner ce carrousel. | Lot 4 "modernisation JS", 18/09/2026 | | `static/game/js/play/dialogue-box-controller.js` (`forgeShowQuizBox`, ligne du `void widget.offsetWidth;`) | `javascript:S3735` | Force une lecture de mise en page (reflow) AVANT de reposer la classe `is-active`, pour que l'animation CSS d'entrée du quiz rejoue même si la boîte était déjà active juste avant (2 questions à la suite) — idiome JS standard, `void` marque explicitement une expression dont seul l'EFFET DE LECTURE compte, jamais la valeur. 3 formes essayées dans l'ordre, chacune rejetée par une règle Sonar différente : `void widget.offsetWidth;` (S3735, "retirer void") → `widget.offsetWidth;` seule (S905, "expression sans effet — accepté par ESLint ici, `no-unused-expressions` est désactivé dans ce projet, mais pas par Sonar") → `const _ = widget.offsetWidth;` (S1481, "variable jamais lue — accepté par ESLint via `varsIgnorePattern: ^_$`, pas par Sonar"). Aucune forme ne satisfait Sonar sans en recréer une autre : `void` restauré (la plus lisible/idiomatique des 3, et la seule aussi acceptée par ESLint) et documenté ici plutôt que de continuer à faire tourner ce carrousel. | Lot 4 "modernisation JS", 18/09/2026 |
| `static/game/js/screen_edit/tree-panels.js:32` (`restoreTreeCollapsedState`), `:57` (`saveFloatPanelState`), `:230` (sauvegarde état replié/déplié au clic) | `javascript:S2486` | Lecture/écriture `localStorage` purement cosmétique (éditeur seulement, jamais le jeu) : un échec (quota, storage désactivé) laisse au pire un panneau à sa position par défaut ou un nœud d'arborescence dans son état précédent — aucune donnée de jeu en jeu, aucun état perdu de façon irréversible. | Lot 3 "modernisation JS", 16/09/2026 | | `static/game/js/screen_edit/tree-panels.js:32` (`restoreTreeCollapsedState`), `:57` (`saveFloatPanelState`), `:230` (sauvegarde état replié/déplié au clic) | `javascript:S2486` | Lecture/écriture `localStorage` purement cosmétique (éditeur seulement, jamais le jeu) : un échec (quota, storage désactivé) laisse au pire un panneau à sa position par défaut ou un nœud d'arborescence dans son état précédent — aucune donnée de jeu en jeu, aucun état perdu de façon irréversible. | Lot 3 "modernisation JS", 16/09/2026 |
@@ -152,6 +152,7 @@ SonarQube : voir section 2, sous-section "SonarQube" — CI restaurée (non-bloq
| `static/game/js/play/offline/filter-repeater-rows.js:24` (`forgeDecodeClauses`) | `javascript:S2486` | **Corrigé, même patron que ci-dessus** : `_filtres_json` est un attribut rendu par le serveur, jamais tapé à la main — un JSON invalide y trahit presque toujours un bug côté éditeur/serveur. `console.warn('_filtres_json invalide, filtre ignoré', e)` ajouté, comportement inchangé (repli sur l'ancien format à 2 filtres fixes ou aucun filtre). Couvert par un nouveau test (`filter-repeater-rows.test.js`, `forgeDecodeClauses — _filtres_json invalide`). | Lot 4 "modernisation JS", 18/09/2026 | | `static/game/js/play/offline/filter-repeater-rows.js:24` (`forgeDecodeClauses`) | `javascript:S2486` | **Corrigé, même patron que ci-dessus** : `_filtres_json` est un attribut rendu par le serveur, jamais tapé à la main — un JSON invalide y trahit presque toujours un bug côté éditeur/serveur. `console.warn('_filtres_json invalide, filtre ignoré', e)` ajouté, comportement inchangé (repli sur l'ancien format à 2 filtres fixes ou aucun filtre). Couvert par un nouveau test (`filter-repeater-rows.test.js`, `forgeDecodeClauses — _filtres_json invalide`). | Lot 4 "modernisation JS", 18/09/2026 |
| `static/game/js/play/offline/filter-repeater-rows.js:59`, `:70` (`forgeResolveVariablePath`, JSON.parse + navigation `.champ`/`[index]`) | `javascript:S2486` | **Documenté, pas corrigé — nature différente du cas ci-dessus** : ici `rawValue` est la VALEUR ACTUELLE d'une variable de jeu (modifiable librement par n'importe quelle action "Modifier une variable"), pas une config interne à l'éditeur — un chemin qui ne correspond pas à sa forme actuelle est un cas normal et attendu (ex. variable encore à sa valeur par défaut non-JSON), déjà explicitement documenté par le commentaire de la fonction ("Ne lève jamais... même convention que côté serveur"). Un `console.warn` ici bruiterait la console à chaque usage légitime. | Lot 4 "modernisation JS", 18/09/2026 | | `static/game/js/play/offline/filter-repeater-rows.js:59`, `:70` (`forgeResolveVariablePath`, JSON.parse + navigation `.champ`/`[index]`) | `javascript:S2486` | **Documenté, pas corrigé — nature différente du cas ci-dessus** : ici `rawValue` est la VALEUR ACTUELLE d'une variable de jeu (modifiable librement par n'importe quelle action "Modifier une variable"), pas une config interne à l'éditeur — un chemin qui ne correspond pas à sa forme actuelle est un cas normal et attendu (ex. variable encore à sa valeur par défaut non-JSON), déjà explicitement documenté par le commentaire de la fonction ("Ne lève jamais... même convention que côté serveur"). Un `console.warn` ici bruiterait la console à chaque usage légitime. | Lot 4 "modernisation JS", 18/09/2026 |
| `static/document/js/document-editor.js` (`FORGE_DOC_STYLE_PRESETS`, `FORGE_DOC_SHAPE_KINDS`, `FORGE_DOC_SNAP_SIZE` — 3 sites) | `eslint:no-var`, `eslint:vars-on-top` | Constantes de premier niveau déclarées en `var` plutôt que `const` : un `<script src>` de page est rejoué TEL QUEL par `pjax.js` (`runScriptsIn`) à chaque navigation interne — une redéclaration `let`/`const` au premier niveau lèverait `SyntaxError: already declared` à la 2e exécution (voir l'en-tête de `static/pjax.js`, et le commentaire d'en-tête de ce fichier). `var` est le seul mot-clé sûr à ce niveau ; tout le reste du fichier (état mutable, y compris à l'intérieur des fonctions) est bien en `let`/`const`, porté par `window.forgeDocState` plutôt que par des variables de module (même convention que `static/game/js/scenes/scene-editor.js` et les autres scripts de page existants, qui n'ont eux aucune constante de ce genre à déclarer). | Session du 20/09/2026 | | `static/document/js/document-editor.js` (`FORGE_DOC_STYLE_PRESETS`, `FORGE_DOC_SHAPE_KINDS`, `FORGE_DOC_SNAP_SIZE` — 3 sites) | `eslint:no-var`, `eslint:vars-on-top` | Constantes de premier niveau déclarées en `var` plutôt que `const` : un `<script src>` de page est rejoué TEL QUEL par `pjax.js` (`runScriptsIn`) à chaque navigation interne — une redéclaration `let`/`const` au premier niveau lèverait `SyntaxError: already declared` à la 2e exécution (voir l'en-tête de `static/pjax.js`, et le commentaire d'en-tête de ce fichier). `var` est le seul mot-clé sûr à ce niveau ; tout le reste du fichier (état mutable, y compris à l'intérieur des fonctions) est bien en `let`/`const`, porté par `window.forgeDocState` plutôt que par des variables de module (même convention que `static/game/js/scenes/scene-editor.js` et les autres scripts de page existants, qui n'ont eux aucune constante de ce genre à déclarer). | Session du 20/09/2026 |
| `templates/document/document_edit.html` (`.docPageRow`, rangées de l'onglet "Pages" du panneau gauche) + `static/document/js/document-editor.js` (`forgeDocRenderPageManagerList`) | `Web:S6819`, `Web:MouseEventWithoutKeyboardEquivalentCheck` (Sonar) | `div role="button" tabindex="0"` volontaire : chaque rangée contient de vrais `<button>` d'action (renommer/supprimer, voir `.docPageRowActions` — le réordonnancement, ancien 3ᵉ/4ᵉ bouton monter/descendre, est passé au glisser-déposer seul le 23/09/2026, retour utilisateur : "je pouvais changer l'ordre des pages en glisser déposer donc les flèches [...] sont inutiles"), qu'un `<button>` englobant ne pourrait pas contenir validement (imbrication de `<button>` invalide, le parseur HTML referme le bouton englobant trop tôt — même défaut structurel déjà rencontré et corrigé de la même façon ailleurs dans ce fichier). L'équivalent clavier (Entrée/Espace déclenche `forgeDocSwitchPage`, même effet que le clic) est posé côté JS (`row.addEventListener('keydown', ...)`), donc le finding clavier de Sonar est un faux positif : l'analyseur statique ne voit pas les `addEventListener` attachés dynamiquement. Vérifié par un test jsdom dédié (rôle `button`, équivalent clavier fonctionnel). | Session du 21/09/2026 ; renommé (panneau à onglets) le 23/09/2026 ; boutons monter/descendre retirés le 23/09/2026 |
### Détail — `javascript:S8786` (ReDoS), lot 1 "modernisation JS" ### Détail — `javascript:S8786` (ReDoS), lot 1 "modernisation JS"
+8
View File
@@ -78,7 +78,11 @@ from .scoring.set_status import set_status
from .slugify import slugify from .slugify import slugify
from .supports.create_support import create_support from .supports.create_support import create_support
from .supports.delete_support import delete_support from .supports.delete_support import delete_support
from .supports.get_document_theme import get_document_theme
from .supports.list_supports import list_supports from .supports.list_supports import list_supports
from .supports.remove_document_theme import remove_document_theme
from .supports.set_document_theme import set_document_theme
from .supports.support_dir import support_dir
from .supports.support_meta import support_meta from .supports.support_meta import support_meta
from .table_name_for import table_name_for from .table_name_for import table_name_for
@@ -115,8 +119,12 @@ __all__ = [
"set_scorm_version", "set_scorm_version",
"create_support", "create_support",
"list_supports", "list_supports",
"support_dir",
"support_meta", "support_meta",
"delete_support", "delete_support",
"get_document_theme",
"remove_document_theme",
"set_document_theme",
"ONBOARDING_TYPES", "ONBOARDING_TYPES",
"DEFAULT_ONBOARDING_TYPE", "DEFAULT_ONBOARDING_TYPE",
"get_onboarding_type", "get_onboarding_type",
+8
View File
@@ -6,7 +6,11 @@ indépendance."""
from .create_support import create_support from .create_support import create_support
from .delete_support import delete_support from .delete_support import delete_support
from .ensure_document_pages_schema import ensure_document_pages_schema
from .get_document_theme import get_document_theme
from .list_supports import list_supports from .list_supports import list_supports
from .remove_document_theme import remove_document_theme
from .set_document_theme import set_document_theme
from .support_connection import connect_support, install_support_teardown_safety_net from .support_connection import connect_support, install_support_teardown_safety_net
from .support_dir import support_dir from .support_dir import support_dir
from .support_meta import support_meta from .support_meta import support_meta
@@ -18,8 +22,12 @@ __all__ = [
"connect_support", "connect_support",
"create_support", "create_support",
"delete_support", "delete_support",
"ensure_document_pages_schema",
"get_document_theme",
"install_support_teardown_safety_net", "install_support_teardown_safety_net",
"list_supports", "list_supports",
"remove_document_theme",
"set_document_theme",
"split_slug", "split_slug",
"support_dir", "support_dir",
"support_meta", "support_meta",
+20 -3
View File
@@ -10,9 +10,17 @@ def create_support(name: str, owner_folder: str) -> str:
"""Crée le dossier du support (projects/<owner_folder>/_supports/ """Crée le dossier du support (projects/<owner_folder>/_supports/
<project_part>/) et sa base support.db dédiée — mirroir de <project_part>/) et sa base support.db dédiée — mirroir de
db/games/create_game.py pour la mécanique de dossier/unicité de nom, db/games/create_game.py pour la mécanique de dossier/unicité de nom,
mais un schéma entièrement différent (voir document_engine/ : un seul mais un schéma entièrement différent (voir document_engine/ : un
document par support, pas d'écrans/objets de scène). owner_folder support est composé de PAGES — _document_pages —, chacune portant son
n'est jamais optionnel ici (contrairement à create_game) : un support propre flux d'éléments via _document_elements.page_id, jamais
d'écrans/objets de scène comme côté jeu). Créé ici SANS aucune page
(retour utilisateur : l'éditeur doit pouvoir s'ouvrir vide, "nouveau
projet par ex") — l'utilisateur clique "+ Ajouter une page" pour
commencer (voir document_engine/pages/add_document_page.py). Aucune
page n'est donc plus une garantie côté production ; seule la fixture
de test `support` (tests/conftest.py) en crée une par convénience pour
les tests qui ne portent pas sur ce cas précis. owner_folder n'est
jamais optionnel ici (contrairement à create_game) : un support
n'existe pas sans compte propriétaire.""" n'existe pas sans compte propriétaire."""
project_part = slugify(name) project_part = slugify(name)
base = project_part base = project_part
@@ -30,8 +38,17 @@ def create_support(name: str, owner_folder: str) -> str:
""" """
CREATE TABLE _meta (key TEXT PRIMARY KEY, value TEXT); CREATE TABLE _meta (key TEXT PRIMARY KEY, value TEXT);
CREATE TABLE _document_pages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL DEFAULT 'Page 1',
order_index INTEGER NOT NULL DEFAULT 0,
vertical_align TEXT NOT NULL DEFAULT 'top',
created_at TEXT DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE _document_elements ( CREATE TABLE _document_elements (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
page_id INTEGER NOT NULL REFERENCES _document_pages(id) ON DELETE CASCADE,
parent_id INTEGER REFERENCES _document_elements(id) ON DELETE CASCADE, parent_id INTEGER REFERENCES _document_elements(id) ON DELETE CASCADE,
kind TEXT NOT NULL, kind TEXT NOT NULL,
order_index INTEGER NOT NULL DEFAULT 0, order_index INTEGER NOT NULL DEFAULT 0,
@@ -0,0 +1,58 @@
from .support_connection import connect_support
def ensure_document_pages_schema(slug: str) -> None:
"""Migration légère (même principe que db/global_vars/
ensure_global_vars_schema.py) : crée _document_pages si absente, et
ajoute page_id à _document_elements si absent — un support créé avant
le système de pages avait un seul document implicite ; ses éléments
existants deviennent tous les enfants d'une page "Page 1" créée ici
automatiquement (comportement le plus proche de l'ancien : un seul
document visible, qui devient simplement sa première page).
Ne recrée PLUS "Page 1" à chaque appel dès que _document_pages est
vide (bug qui empêchait tout support d'atteindre 0 page — un support
sans aucune page est un état valide depuis le retour utilisateur
"l'éditeur doit pouvoir s'ouvrir sans aucune page"). La création
automatique de "Page 1" ne se déclenche plus qu'une seule fois, au
moment précis de cette migration historique (juste avant d'ajouter la
colonne page_id, voir plus bas) — jamais ensuite."""
conn = connect_support(slug)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS _document_pages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL DEFAULT 'Page 1',
order_index INTEGER NOT NULL DEFAULT 0,
created_at TEXT DEFAULT CURRENT_TIMESTAMP
);
"""
)
# vertical_align : alignement vertical du contenu de la page
# (top/center/bottom — voir document_engine/pages/
# set_document_page_vertical_align.py), ajouté après la création
# initiale des pages — même pragmatisme que page_id ci-dessous, une
# valeur par défaut CONSTANTE plutôt qu'une contrainte CHECK.
page_cols = {r["name"] for r in conn.execute("PRAGMA table_info(_document_pages)").fetchall()}
if "vertical_align" not in page_cols:
conn.execute("ALTER TABLE _document_pages ADD COLUMN vertical_align TEXT NOT NULL DEFAULT 'top'")
cols = {r["name"] for r in conn.execute("PRAGMA table_info(_document_elements)").fetchall()}
if "page_id" not in cols:
# Vrai support pré-pages : ses éléments existants (s'il y en a)
# doivent atterrir quelque part — jamais recréé une fois cette
# migration ponctuelle passée (page_id existera alors déjà).
page_row = conn.execute("SELECT id FROM _document_pages ORDER BY order_index LIMIT 1").fetchone()
if page_row is None:
conn.execute("INSERT INTO _document_pages (title, order_index) VALUES ('Page 1', 0)")
page_row = conn.execute("SELECT id FROM _document_pages ORDER BY order_index LIMIT 1").fetchone()
first_page_id = page_row["id"]
# SQLite autorise ADD COLUMN avec une valeur par défaut CONSTANTE
# (jamais une contrainte REFERENCES ici, même pragmatisme que
# db/rows/ensure_player_id_column.py : la contrainte de clé
# étrangère n'existe que pour les supports créés APRÈS ce
# changement, via create_support.py).
conn.execute(f"ALTER TABLE _document_elements ADD COLUMN page_id INTEGER NOT NULL DEFAULT {first_page_id}")
conn.commit()
conn.close()
+13
View File
@@ -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
+15
View File
@@ -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()
+13
View File
@@ -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()
+2
View File
@@ -1,5 +1,6 @@
from typing import Any from typing import Any
from .get_document_theme import get_document_theme
from .support_connection import connect_support from .support_connection import connect_support
@@ -10,4 +11,5 @@ def support_meta(slug: str) -> dict[str, Any]:
return { return {
"slug": slug, "slug": slug,
"name": row["value"] if row else slug, "name": row["value"] if row else slug,
"theme": get_document_theme(slug),
} }
+545
View File
@@ -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.
+71 -12
View File
@@ -1,16 +1,17 @@
""" """
document_engine — support de formation : entité racine séparée du jeu 2D document_engine — support de formation : entité racine séparée du jeu 2D
(voir docs/plan/PLAN.md). Un support = un seul document, structuré en deux (voir docs/plan/PLAN.md). Un support est composé de plusieurs PAGES (voir
couches sur le même canevas : document_engine/pages/, retour utilisateur du 21/09/2026 : "il faut
- le flux de contenu (titre/paragraphe/image/bouton/mini-jeux), organisé implémenter un système de page") ; chaque page structure son propre
en rangées ("row") par le moteur d'inférence de layout (voir contenu en deux couches sur le même canevas :
document_engine/rendering/render_document_element.py) ; le flux de contenu (titre/paragraphe/image/bouton/mini-jeux), organisé en
- la couche de formes libres (rectangle/cercle/triangle/trait), position- rangées ("row") par le moteur d'inférence de layout (voir
nées en absolu (x/y/width/height/rotation/z_index). document_engine/rendering/render_document_element.py).
Tout est stocké dans support.db (voir db/supports/) : une seule table, Tout est stocké dans support.db (voir db/supports/) : _document_pages (une
_document_elements, portant les deux couches via parent_id (NULL = top- ligne par page) et _document_elements (chaque élément appartient à
niveau ou forme libre, sinon = enfant d'une rangée). exactement une page via page_id, avec parent_id NULL au top-niveau ou
enfant d'une rangée).
Aucun import croisé avec game_engine ou tout module lié au jeu 2D — voir Aucun import croisé avec game_engine ou tout module lié au jeu 2D — voir
le contrat import-linter dans pyproject.toml (game_engine | document_engine le contrat import-linter dans pyproject.toml (game_engine | document_engine
@@ -26,6 +27,7 @@ from .elements.delete_document_element import delete_document_element
from .elements.get_document_element import get_document_element from .elements.get_document_element import get_document_element
from .elements.list_document_elements import list_document_elements from .elements.list_document_elements import list_document_elements
from .elements.move_document_element import move_document_element from .elements.move_document_element import move_document_element
from .elements.move_document_element_to_page import move_document_element_to_page
from .elements.update_document_element_attributes import update_document_element_attributes from .elements.update_document_element_attributes import update_document_element_attributes
from .labels.association_config import ( from .labels.association_config import (
DEFAULT_ASSOCIATION_CONFIG, DEFAULT_ASSOCIATION_CONFIG,
@@ -38,9 +40,21 @@ from .labels.element_kind_labels import (
ELEMENT_KIND_LABELS, ELEMENT_KIND_LABELS,
ELEMENT_LIBRARY, ELEMENT_LIBRARY,
MINIGAME_KINDS, MINIGAME_KINDS,
SHAPE_KINDS,
element_default_attributes, element_default_attributes,
) )
from .labels.memory_config import (
CARD_MODES,
DEFAULT_MEMORY_CONFIG,
MAX_CARDS,
MIN_CARDS,
sanitize_memory_config,
)
from .labels.mots_config import (
DEFAULT_MOTS_CONFIG,
MAX_WORDS,
MIN_WORDS,
sanitize_mots_config,
)
from .labels.quiz_config import ( from .labels.quiz_config import (
DEFAULT_QUIZ_CONFIG, DEFAULT_QUIZ_CONFIG,
MAX_CHOICES, MAX_CHOICES,
@@ -50,32 +64,77 @@ from .labels.quiz_config import (
quiz_total_points, quiz_total_points,
sanitize_quiz_config, sanitize_quiz_config,
) )
from .labels.sanitize_element_attributes import sanitize_element_attributes
from .labels.scenario_config import (
DEFAULT_SCENARIO_CONFIG,
MAX_SCENARIO_CHOICES,
sanitize_scenario_config,
)
from .pages.add_document_page import add_document_page
from .pages.delete_all_document_pages import delete_all_document_pages
from .pages.delete_document_page import delete_document_page
from .pages.get_document_page import get_document_page
from .pages.list_document_pages import list_document_pages
from .pages.move_document_page import move_document_page
from .pages.replace_document_content import replace_document_content
from .pages.set_document_page_vertical_align import VERTICAL_ALIGNS, set_document_page_vertical_align
from .pages.update_document_page import update_document_page
from .rendering.render_document_element import render_document, render_document_element from .rendering.render_document_element import render_document, render_document_element
from .rendering.sanitize_svg_markup import sanitize_svg_markup
from .themes.seed_blocks_to_elements import seed_blocks_to_elements
from .themes.theme_catalog import DOCUMENT_THEMES, get_document_theme_entry
__all__ = [ __all__ = [
"CARD_MODES",
"CONTENT_KINDS", "CONTENT_KINDS",
"DEFAULT_ASSOCIATION_CONFIG", "DEFAULT_ASSOCIATION_CONFIG",
"DEFAULT_MEMORY_CONFIG",
"DEFAULT_MOTS_CONFIG",
"DEFAULT_QUIZ_CONFIG", "DEFAULT_QUIZ_CONFIG",
"DEFAULT_SCENARIO_CONFIG",
"DOCUMENT_THEMES",
"ELEMENT_KIND_LABELS", "ELEMENT_KIND_LABELS",
"ELEMENT_LIBRARY", "ELEMENT_LIBRARY",
"MAX_CARDS",
"MAX_CHOICES", "MAX_CHOICES",
"MAX_PAIRS", "MAX_PAIRS",
"MAX_SCENARIO_CHOICES",
"MAX_TIMER_SECONDS", "MAX_TIMER_SECONDS",
"MAX_WORDS",
"MIN_CARDS",
"MIN_CHOICES", "MIN_CHOICES",
"MIN_PAIRS", "MIN_PAIRS",
"MIN_TIMER_SECONDS", "MIN_TIMER_SECONDS",
"MIN_WORDS",
"MINIGAME_KINDS", "MINIGAME_KINDS",
"SHAPE_KINDS", "VERTICAL_ALIGNS",
"add_document_element", "add_document_element",
"add_document_page",
"delete_all_document_pages",
"delete_document_element", "delete_document_element",
"delete_document_page",
"element_default_attributes", "element_default_attributes",
"get_document_element", "get_document_element",
"get_document_page",
"get_document_theme_entry",
"list_document_elements", "list_document_elements",
"list_document_pages",
"move_document_element", "move_document_element",
"move_document_element_to_page",
"move_document_page",
"quiz_total_points", "quiz_total_points",
"render_document", "render_document",
"render_document_element", "render_document_element",
"replace_document_content",
"sanitize_association_config", "sanitize_association_config",
"sanitize_element_attributes",
"sanitize_memory_config",
"sanitize_mots_config",
"sanitize_quiz_config", "sanitize_quiz_config",
"sanitize_scenario_config",
"sanitize_svg_markup",
"seed_blocks_to_elements",
"set_document_page_vertical_align",
"update_document_element_attributes", "update_document_element_attributes",
"update_document_page",
] ]
+5 -1
View File
@@ -1,15 +1,19 @@
# document_engine/ # document_engine/
Moteur du support de formation — entité racine séparée du jeu 2D (voir Moteur du support de formation — entité racine séparée du jeu 2D (voir
`docs/plan/PLAN.md`). Package composé de trois sous-dossiers, chacun `docs/plan/PLAN.md`). Package composé de cinq sous-dossiers, chacun
documenté séparément : documenté séparément :
- [`elements/`](elements/elements.md) — CRUD des éléments du document - [`elements/`](elements/elements.md) — CRUD des éléments du document
(`_document_elements`). (`_document_elements`).
- [`labels/`](labels/labels.md) — catalogue statique des types d'éléments - [`labels/`](labels/labels.md) — catalogue statique des types d'éléments
(bibliothèque, libellés, attributs par défaut). (bibliothèque, libellés, attributs par défaut).
- [`pages/`](pages/pages.md) — CRUD des pages d'un support
(`_document_pages`) et remplacement complet du contenu depuis un thème.
- [`rendering/`](rendering/rendering.md) — rendu HTML du document (canevas - [`rendering/`](rendering/rendering.md) — rendu HTML du document (canevas
d'édition et Mode Aperçu, même fonction). d'édition et Mode Aperçu, même fonction).
- [`themes/`](themes/themes.md) — catalogue des thèmes visuels
applicables à un support (bouton "Utiliser un modèle").
`document_engine/__init__.py` ré-exporte l'intégralité de l'API publique `document_engine/__init__.py` ré-exporte l'intégralité de l'API publique
du paquet (voir son `__all__`), pattern identique à `game_engine/__init__.py`. du paquet (voir son `__all__`), pattern identique à `game_engine/__init__.py`.
@@ -1,23 +1,29 @@
import json import json
from db.supports import connect_support from db.supports import connect_support, ensure_document_pages_schema
from ..labels.element_kind_labels import element_default_attributes from ..labels.element_kind_labels import element_default_attributes
def add_document_element(slug: str, kind: str, parent_id: int | None = None) -> int: def add_document_element(slug: str, kind: str, page_id: int, parent_id: int | None = None) -> int:
"""Ajoute un élément en fin de son groupe de frères (même parent_id — """Ajoute un élément à une page précise, en fin de son groupe de
NULL pour un élément top-niveau, l'id d'une rangée pour un enfant de frères (même parent_id — NULL pour un élément top-niveau, l'id d'une
cette rangée, voir docs/plan/PLAN.md). Attributs de départ posés via rangée pour un enfant de cette rangée, voir docs/plan/PLAN.md).
element_default_attributes(kind).""" Attributs de départ posés via element_default_attributes(kind). Le
groupe de frères (parent_id) est TOUJOURS cherché à l'intérieur de
cette même page — deux pages peuvent chacune avoir une rangée dont
les enfants portent des id `parent_id` différents, jamais de
confusion possible entre pages."""
ensure_document_pages_schema(slug)
conn = connect_support(slug) conn = connect_support(slug)
max_row = conn.execute( max_row = conn.execute(
"SELECT MAX(order_index) AS m FROM _document_elements WHERE parent_id IS ?", (parent_id,) "SELECT MAX(order_index) AS m FROM _document_elements WHERE page_id = ? AND parent_id IS ?",
(page_id, parent_id),
).fetchone() ).fetchone()
order_index = (max_row["m"] or 0) + 1 if max_row and max_row["m"] is not None else 0 order_index = (max_row["m"] or 0) + 1 if max_row and max_row["m"] is not None else 0
conn.execute( conn.execute(
"INSERT INTO _document_elements (parent_id, kind, order_index, attributes) VALUES (?, ?, ?, ?)", "INSERT INTO _document_elements (page_id, parent_id, kind, order_index, attributes) VALUES (?, ?, ?, ?, ?)",
(parent_id, kind, order_index, json.dumps(element_default_attributes(kind))), (page_id, parent_id, kind, order_index, json.dumps(element_default_attributes(kind))),
) )
element_id = int(conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"]) element_id = int(conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"])
conn.commit() conn.commit()
@@ -1,10 +1,11 @@
from db.supports import connect_support from db.supports import connect_support, ensure_document_pages_schema
def delete_document_element(slug: str, element_id: int) -> None: def delete_document_element(slug: str, element_id: int) -> None:
"""Supprime un élément — CASCADE (contrainte FK, voir """Supprime un élément — CASCADE (contrainte FK, voir
db/supports/create_support.py) retire aussi ses enfants si c'était une db/supports/create_support.py) retire aussi ses enfants si c'était une
rangée.""" rangée."""
ensure_document_pages_schema(slug)
conn = connect_support(slug) conn = connect_support(slug)
conn.execute("DELETE FROM _document_elements WHERE id = ?", (element_id,)) conn.execute("DELETE FROM _document_elements WHERE id = ?", (element_id,))
conn.commit() conn.commit()
+37 -10
View File
@@ -1,23 +1,29 @@
# document_engine/elements/ # document_engine/elements/
CRUD des éléments d'un support de formation (`_document_elements`, voir CRUD des éléments d'UNE PAGE d'un support de formation
`db/supports/create_support.py`). Un élément est soit un élément de (`_document_elements`, voir `db/supports/create_support.py`) — un
contenu/rangée du flux (`parent_id` = groupe de frères), soit une forme support est composé de plusieurs pages (voir `document_engine/pages/`),
libre superposée en position absolue (toujours `parent_id = NULL`). chaque élément appartient à exactement une page via `page_id`, jamais
partagé entre pages. Un élément est soit un élément de contenu/rangée du
flux (`parent_id` = groupe de frères, toujours dans la MÊME page), soit
une forme libre superposée en position absolue (toujours `parent_id =
NULL`).
## `add_document_element(slug: str, kind: str, parent_id: int | None = None) -> int` ## `add_document_element(slug: str, kind: str, page_id: int, parent_id: int | None = None) -> int`
Insère un nouvel élément en fin de son groupe de frères (même `parent_id`). Insère un nouvel élément dans `page_id`, en fin de son groupe de frères
(même `parent_id`, recherché uniquement à l'intérieur de cette page).
Pose les attributs de départ via `element_default_attributes(kind)` Pose les attributs de départ via `element_default_attributes(kind)`
(voir `document_engine/labels/element_kind_labels.py`). (voir `document_engine/labels/element_kind_labels.py`).
- **Retour** : l'`id` du nouvel élément. - **Retour** : l'`id` du nouvel élément.
- **Exceptions** : aucune levée explicitement ; une connexion invalide - **Exceptions** : aucune levée explicitement ; une connexion invalide
(support inexistant) lève l'erreur SQLite sous-jacente. (support inexistant) lève l'erreur SQLite sous-jacente.
## `list_document_elements(slug: str) -> list[dict[str, Any]]` ## `list_document_elements(slug: str, page_id: int) -> list[dict[str, Any]]`
Renvoie tous les éléments du support, à plat, triés par `(parent_id, Renvoie tous les éléments de `page_id`, à plat, triés par `(parent_id,
order_index)` — les éléments top-niveau (`parent_id` NULL) groupés en order_index)` — les éléments top-niveau (`parent_id` NULL) groupés en
premier (tri SQLite : NULL avant toute valeur), puis chaque rangée premier (tri SQLite : NULL avant toute valeur), puis chaque rangée
groupant ses propres enfants. `attributes` est décodé en dict. groupant ses propres enfants. `attributes` est décodé en dict. Le
canevas n'affiche jamais qu'une seule page à la fois, d'où ce filtrage.
- **Retour** : liste de dicts (une ligne de table chacun, `attributes` - **Retour** : liste de dicts (une ligne de table chacun, `attributes`
déjà en JSON décodé). déjà en JSON décodé).
- **Exceptions** : aucune. - **Exceptions** : aucune.
@@ -42,7 +48,28 @@ de layout : dépose l'élément dans un groupe de frères (nouvelle rangée,
un groupe existant, ou le top-niveau) à une position précise, puis un groupe existant, ou le top-niveau) à une position précise, puis
renumérote intégralement le ou les groupes concernés (ancien et nouveau renumérote intégralement le ou les groupes concernés (ancien et nouveau
si le parent change, un seul sinon) pour rester correct même en cas de si le parent change, un seul sinon) pour rester correct même en cas de
réordonnancement dans le même groupe. réordonnancement dans le même groupe. `new_parent_id` doit toujours
désigner un élément de la MÊME page que `element_id` — jamais vérifié
ici (le client ne propose que des cibles de la page actuellement
affichée).
- **Retour** : aucun.
- **Exceptions** : aucune levée explicitement ; un `element_id`
inexistant ne fait rien (retour silencieux après vérification de son
existence).
## `move_document_element_to_page(slug: str, element_id: int, target_page_id: int) -> None`
Déplace un élément vers une AUTRE page du même support — utilisé par la
pagination automatique (retour utilisateur du 23/09/2026 : quand le
contenu déborde d'une page, l'élément en trop est déplacé vers une
nouvelle page plutôt que d'y rester tassé). L'élément redevient TOUJOURS
top-niveau sur la page cible (`parent_id` remis à `NULL`) — une rangée
qui existait sur l'ancienne page n'a aucun sens comme enfant d'une
rangée de la page cible. Si l'élément déplacé est lui-même une rangée,
ses enfants directs (même `parent_id`) SUIVENT sur la page cible
(`page_id` mis à jour en cascade, `parent_id` inchangé) — sans cette
cascade ils resteraient orphelins d'une page qu'ils n'occupent plus
(`list_document_elements`, filtré par `page_id`, ne les retrouverait
plus). Renumérote les anciens frères après le retrait.
- **Retour** : aucun. - **Retour** : aucun.
- **Exceptions** : aucune levée explicitement ; un `element_id` - **Exceptions** : aucune levée explicitement ; un `element_id`
inexistant ne fait rien (retour silencieux après vérification de son inexistant ne fait rien (retour silencieux après vérification de son
@@ -1,10 +1,11 @@
import json import json
from typing import Any from typing import Any
from db.supports import connect_support from db.supports import connect_support, ensure_document_pages_schema
def get_document_element(slug: str, element_id: int) -> dict[str, Any] | None: def get_document_element(slug: str, element_id: int) -> dict[str, Any] | None:
ensure_document_pages_schema(slug)
conn = connect_support(slug) conn = connect_support(slug)
row = conn.execute("SELECT * FROM _document_elements WHERE id = ?", (element_id,)).fetchone() row = conn.execute("SELECT * FROM _document_elements WHERE id = ?", (element_id,)).fetchone()
conn.close() conn.close()
@@ -1,21 +1,26 @@
import json import json
from typing import Any from typing import Any
from db.supports import connect_support from db.supports import connect_support, ensure_document_pages_schema
def list_document_elements(slug: str) -> list[dict[str, Any]]: def list_document_elements(slug: str, page_id: int) -> list[dict[str, Any]]:
"""Tous les éléments d'un support, à PLAT — chaque élément porte son """Tous les éléments d'UNE PAGE du support, à PLAT — chaque élément
propre parent_id (NULL = top-niveau, sinon l'id d'une rangée) ; la porte son propre parent_id (NULL = top-niveau, sinon l'id d'une
reconstitution de l'arbre (rangées + leurs enfants dans l'ordre) se rangée) ; la reconstitution de l'arbre (rangées + leurs enfants dans
fait côté rendu (voir document_engine.render_document_element) et l'ordre) se fait côté rendu (voir document_engine.render_document_element)
côté JS pour l'affichage du canevas.""" et côté JS pour l'affichage du canevas. Un support est composé de
plusieurs pages (voir document_engine/pages/) : le canevas n'affiche
jamais qu'une seule page à la fois, d'où ce filtrage par page_id."""
ensure_document_pages_schema(slug)
conn = connect_support(slug) conn = connect_support(slug)
# SQLite trie NULL avant toute valeur : les éléments top-niveau # SQLite trie NULL avant toute valeur : les éléments top-niveau
# (parent_id NULL) arrivent groupés en premier, puis chaque rangée # (parent_id NULL) arrivent groupés en premier, puis chaque rangée
# groupe ses propres enfants — chacun trié par order_index à # groupe ses propres enfants — chacun trié par order_index à
# l'intérieur de son groupe. # l'intérieur de son groupe.
rows = conn.execute("SELECT * FROM _document_elements ORDER BY parent_id, order_index").fetchall() rows = conn.execute(
"SELECT * FROM _document_elements WHERE page_id = ? ORDER BY parent_id, order_index", (page_id,)
).fetchall()
conn.close() conn.close()
elements = [] elements = []
for row in rows: for row in rows:
@@ -1,4 +1,4 @@
from db.supports import connect_support from db.supports import connect_support, ensure_document_pages_schema
def move_document_element(slug: str, element_id: int, new_parent_id: int | None, new_index: int) -> None: def move_document_element(slug: str, element_id: int, new_parent_id: int | None, new_index: int) -> None:
@@ -8,7 +8,11 @@ def move_document_element(slug: str, element_id: int, new_parent_id: int | None,
une position précise, pas juste "monter/descendre" d'un cran. Renumérote une position précise, pas juste "monter/descendre" d'un cran. Renumérote
intégralement les deux groupes concernés (ancien et nouveau, ou un seul intégralement les deux groupes concernés (ancien et nouveau, ou un seul
si inchangé) plutôt que de décaler un par un, pour rester correct même si inchangé) plutôt que de décaler un par un, pour rester correct même
en cas de réordonnancement DANS le même groupe.""" en cas de réordonnancement DANS le même groupe. new_parent_id doit
toujours désigner un élément de la MÊME page que element_id (jamais
vérifié ici — le client ne propose que des cibles de la page
actuellement affichée, voir static/document/js/document-editor.js)."""
ensure_document_pages_schema(slug)
conn = connect_support(slug) conn = connect_support(slug)
row = conn.execute("SELECT parent_id FROM _document_elements WHERE id = ?", (element_id,)).fetchone() row = conn.execute("SELECT parent_id FROM _document_elements WHERE id = ?", (element_id,)).fetchone()
if not row: if not row:
@@ -0,0 +1,47 @@
from db.supports import connect_support, ensure_document_pages_schema
def move_document_element_to_page(slug: str, element_id: int, target_page_id: int) -> None:
"""Déplace un élément vers une AUTRE page du même support — utilisé
par la pagination automatique (retour utilisateur du 23/09/2026 :
"si il n'y a plus de place sur la page il faut automatiquement créer
une autre page [et y déplacer] le contenu", voir
static/document/js/document-editor.js, forgeDocCheckPageOverflow).
L'élément redevient TOUJOURS top-niveau sur la page cible (parent_id
NULL) — une rangée qui existait sur l'ancienne page n'a aucun sens
comme enfant d'une rangée de la page cible. Si l'élément déplacé est
lui-même une rangée, ses enfants directs (même parent_id) SUIVENT sur
la page cible (cascade sur page_id, parent_id inchangé) : sans cette
cascade, list_document_elements (filtré par page_id) ne les
retrouverait plus, alors qu'ils resteraient en base rattachés à une
rangée désormais sur une autre page — état incohérent silencieux."""
ensure_document_pages_schema(slug)
conn = connect_support(slug)
row = conn.execute("SELECT page_id, parent_id FROM _document_elements WHERE id = ?", (element_id,)).fetchone()
if not row:
conn.close()
return
old_page_id = row["page_id"]
old_parent_id = row["parent_id"]
old_siblings = [
r["id"]
for r in conn.execute(
"SELECT id FROM _document_elements WHERE page_id = ? AND parent_id IS ? AND id != ? ORDER BY order_index",
(old_page_id, old_parent_id, element_id),
).fetchall()
]
for index, sibling_id in enumerate(old_siblings):
conn.execute("UPDATE _document_elements SET order_index = ? WHERE id = ?", (index, sibling_id))
new_top_level_count = conn.execute(
"SELECT COUNT(*) AS n FROM _document_elements WHERE page_id = ? AND parent_id IS NULL", (target_page_id,)
).fetchone()["n"]
conn.execute(
"UPDATE _document_elements SET page_id = ?, parent_id = NULL, order_index = ? WHERE id = ?",
(target_page_id, new_top_level_count, element_id),
)
conn.execute("UPDATE _document_elements SET page_id = ? WHERE parent_id = ?", (target_page_id, element_id))
conn.commit()
conn.close()
@@ -1,7 +1,7 @@
import json import json
from typing import Any from typing import Any
from db.supports import connect_support from db.supports import connect_support, ensure_document_pages_schema
def update_document_element_attributes(slug: str, element_id: int, attributes: dict[str, Any]) -> None: def update_document_element_attributes(slug: str, element_id: int, attributes: dict[str, Any]) -> None:
@@ -9,6 +9,7 @@ def update_document_element_attributes(slug: str, element_id: int, attributes: d
Propriétés envoie systématiquement l'état entier de ses champs, jamais Propriétés envoie systématiquement l'état entier de ses champs, jamais
un patch partiel (voir docs/plan/PLAN.md §1.2.D : "chaque formulaire un patch partiel (voir docs/plan/PLAN.md §1.2.D : "chaque formulaire
est autonome").""" est autonome")."""
ensure_document_pages_schema(slug)
conn = connect_support(slug) conn = connect_support(slug)
conn.execute( conn.execute(
"UPDATE _document_elements SET attributes = ? WHERE id = ?", "UPDATE _document_elements SET attributes = ? WHERE id = ?",
+185 -28
View File
@@ -5,32 +5,34 @@ défaut posés à la création de chaque type."""
from typing import Any from typing import Any
from ..rendering.box_style import BOX_DEFAULTS, default_border
from .association_config import DEFAULT_ASSOCIATION_CONFIG from .association_config import DEFAULT_ASSOCIATION_CONFIG
from .memory_config import DEFAULT_MEMORY_CONFIG
from .mots_config import DEFAULT_MOTS_CONFIG
from .quiz_config import DEFAULT_QUIZ_CONFIG from .quiz_config import DEFAULT_QUIZ_CONFIG
from .scenario_config import DEFAULT_SCENARIO_CONFIG
SHAPE_KINDS = ("rectangle", "cercle", "triangle", "trait") CONTENT_KINDS = ("titre", "paragraphe", "image", "bouton", "liste_puces", "liste_numerotee", "badge", "carte")
CONTENT_KINDS = ("titre", "paragraphe", "image", "bouton")
MINIGAME_KINDS = ("quiz", "association", "memory", "mots", "scenario", "zones") MINIGAME_KINDS = ("quiz", "association", "memory", "mots", "scenario", "zones")
# "row" n'apparaît jamais dans la bibliothèque (créé implicitement par le # "row" n'apparaît jamais dans la bibliothèque (créé implicitement par le
# moteur de layout au dépôt d'un élément à côté d'un autre) — absent de # moteur de layout au dépôt d'un élément à côté d'un autre) — absent de
# ELEMENT_LIBRARY, présent dans ELEMENT_KIND_LABELS pour l'affichage/debug. # ELEMENT_LIBRARY, présent dans ELEMENT_KIND_LABELS pour l'affichage/debug.
ELEMENT_LIBRARY: dict[str, dict[str, Any]] = { ELEMENT_LIBRARY: dict[str, dict[str, Any]] = {
"mise_en_page": {"label": "Mise en page", "kinds": list(SHAPE_KINDS)},
"contenu": {"label": "Contenu", "kinds": list(CONTENT_KINDS)}, "contenu": {"label": "Contenu", "kinds": list(CONTENT_KINDS)},
"minigames": {"label": "Mini-jeux", "kinds": list(MINIGAME_KINDS)}, "minigames": {"label": "Mini-jeux", "kinds": list(MINIGAME_KINDS)},
} }
ELEMENT_KIND_LABELS: dict[str, str] = { ELEMENT_KIND_LABELS: dict[str, str] = {
"row": "Rangée", "row": "Rangée",
"rectangle": "Rectangle",
"cercle": "Cercle",
"triangle": "Triangle",
"trait": "Trait",
"titre": "Titre", "titre": "Titre",
"paragraphe": "Paragraphe", "paragraphe": "Paragraphe",
"image": "Image", "image": "Image",
"bouton": "Bouton", "bouton": "Bouton",
"liste_puces": "Liste à puces",
"liste_numerotee": "Liste numérotée",
"badge": "Étiquette",
"carte": "Carte",
"quiz": "Quiz", "quiz": "Quiz",
"association": "Association", "association": "Association",
"memory": "Memory", "memory": "Memory",
@@ -39,25 +41,28 @@ ELEMENT_KIND_LABELS: dict[str, str] = {
"zones": "Zones à risque", "zones": "Zones à risque",
} }
_SHAPE_DEFAULTS = {
"x": 40,
"y": 40,
"width": 160,
"height": 100,
"rotation": 0,
"z_index": 1,
"fill": "#ff5f2e",
"stroke": "#232a38",
"stroke_width": 2,
"label": "",
}
_TEXT_DEFAULTS = { _TEXT_DEFAULTS = {
"bold": False, "bold": False,
"italic": False, "italic": False,
"underline": False, "underline": False,
"strikethrough": False,
"align": "left", "align": "left",
"color": "var(--forge-text)", "color": "var(--forge-text)",
# Vides par défaut = valeurs du préréglage `style` inchangées (voir
# _STYLE_PRESETS dans render_document_element.py) — une valeur
# explicite les remplace (retour utilisateur du 26/09/2026, audit des
# réglages manquants).
"font_family": "",
"font_size": "",
"line_height": "",
"letter_spacing": "",
"text_transform": "none",
"text_shadow": "",
# Attributs de boîte partagés avec d'autres kinds (dont `max_width`,
# ex. "60ch"/"480px" — retour utilisateur du 24/09/2026 : un
# paragraphe doit pouvoir rester plus étroit que la page) — voir
# rendering/box_style.py.
**BOX_DEFAULTS,
} }
@@ -65,16 +70,159 @@ def element_default_attributes(kind: str) -> dict[str, Any]:
"""Attributs posés à la création d'un élément de ce type — cf. """Attributs posés à la création d'un élément de ce type — cf.
docs/plan/PLAN.md §3.3/§3.4/§3.5 pour la liste des propriétés docs/plan/PLAN.md §3.3/§3.4/§3.5 pour la liste des propriétés
éditables par panneau, ici juste leur valeur de départ.""" éditables par panneau, ici juste leur valeur de départ."""
if kind in SHAPE_KINDS:
return dict(_SHAPE_DEFAULTS)
if kind == "titre": if kind == "titre":
return {"content": "Nouveau titre", "style": "titre1", **_TEXT_DEFAULTS} # "border" copié à chaque appel (jamais un dict partagé/muté par
# référence entre plusieurs éléments — même raison que la copie
# de "questions" pour le quiz plus bas).
return {"content": "Nouveau titre", "style": "titre1", **_TEXT_DEFAULTS, "border": default_border()}
if kind == "paragraphe": if kind == "paragraphe":
return {"content": "Nouveau paragraphe de texte.", "style": "paragraphe", **_TEXT_DEFAULTS} return {
"content": "Nouveau paragraphe de texte.",
"style": "paragraphe",
**_TEXT_DEFAULTS,
"border": default_border(),
}
if kind == "image": if kind == "image":
return {"src": "", "alt": ""} # svg_markup (optionnel) prend le pas sur src au rendu (voir
# render_document_element._render_image) — un contenu vectoriel
# dessiné/collé directement plutôt qu'un fichier hébergé.
# aspect_ratio/filter_preset : vides par défaut (aucun style
# ajouté). click_behavior/link_url/lazy_load/caption :
# comportement/contenu, pas du style (retour utilisateur du
# 26/09/2026, audit des réglages manquants — colonne "Image").
# click_behavior ("" | "link" | "lightbox") et link_url sont
# mutuellement dépendants (un lien sans URL ne fait rien au
# rendu, voir _render_image) mais jamais revalidés l'un par
# rapport à l'autre ici : cette combinaison reste sans risque
# quelle qu'elle soit.
#
# object_fit="cover" + height="220px" (au lieu de vides) : cadre
# FIXE par défaut (retour utilisateur du 26/09/2026 : "ce cadre
# ne devrait pas changer de taille en fonction de la taille de
# l'image mais être fixe et contraindre l'image dedans") — une
# photo importée est désormais TOUJOURS rognée pour remplir ce
# cadre, quelle que soit sa résolution native, plutôt que de
# dicter elle-même la taille du bloc. "Taille réelle" reste
# sélectionnable explicitement dans le panneau Propriétés
# (segmented "Ajustement dans son cadre") pour qui préfère
# revenir à l'ancien comportement (hauteur libre, aucun
# object-fit) ; un défaut CSS aveugle sur TOUTE image aurait
# rendu ce choix impossible à distinguer de "jamais réglé", les
# deux valant la chaîne vide.
return {
"src": "",
"alt": "",
"svg_markup": "",
"object_fit": "cover",
"aspect_ratio": "",
"filter_preset": "",
"click_behavior": "",
"link_url": "",
"lazy_load": False,
"caption": "",
**BOX_DEFAULTS,
"height": "220px",
"border": default_border(),
}
if kind == "bouton": if kind == "bouton":
return {"label": "Bouton", "target": ""} # attachment_stored_name/attachment_filename (optionnels) : un
# fichier téléchargeable joint au bouton (voir routes/document/
# document_element_upload_attachment.py), indépendant de `target`
# qui reste réservé à la navigation (URL/ancre). Jamais les deux
# à la fois côté UI (voir document-editor.js), mais rien ne
# l'empêche structurellement ici.
#
# Audit du 26/09/2026 (réglages manquants — colonne "Bouton") :
# bold/italic/text_transform/font_family/font_size/letter_spacing/
# text_color (typo, jamais gérés par box_style.py) + svg_markup/
# icon_position/icon_size (icône avant/après le texte, absente
# même de l'étiquette qui n'a qu'un SVG fixe sans position ni
# taille réglables) + les attributs de boîte partagés. bold=False
# PAR DÉFAUT (le CSS de base garde son font-weight:700 tel quel
# tant que "bold" n'est pas explicitement activé — voir
# _render_button, qui ne pousse à 800 QUE si bold=True — aucune
# régression visuelle sur les boutons déjà créés).
return {
"label": "Bouton",
"target": "",
"attachment_stored_name": "",
"attachment_filename": "",
"bold": False,
"italic": False,
"text_transform": "none",
"font_family": "",
"font_size": "",
"letter_spacing": "",
"text_color": "",
"svg_markup": "",
"icon_position": "before",
"icon_size": "",
**BOX_DEFAULTS,
"border": default_border(),
}
if kind in ("liste_puces", "liste_numerotee"):
# Une seule et même structure d'attributs pour les deux kinds —
# "ordonnée ou non" se lit directement sur le kind au moment du
# rendu (voir render_document_element._render_list), jamais un
# attribut "ordered" redondant à tenir synchronisé avec le kind.
#
# Audit du 26/09/2026 (réglages manquants — colonne "Liste à
# puces/numérotée") : portée actée avec l'utilisateur = style de
# la LISTE ENTIÈRE (typo/puces/boîte/bordure/fond), jamais un
# style par élément individuel ni de sous-listes (chantier bien
# plus lourd, transformerait `items` d'une liste de chaînes en
# objets structurés — différé à une demande séparée). Seule
# exception : `item_padding`, un padding UNIFORME appliqué à
# CHAQUE élément (retour utilisateur explicite : "il faut un
# padding de base par élément de liste car y en a pas
# aujourd'hui") — une valeur PARTAGÉE par tous les éléments,
# jamais réglable individuellement, voir _render_list.
# list_style_type : valide uniquement parmi les valeurs propres
# au kind (disc/circle/square/none pour puces,
# decimal/.../none pour numérotée) — vérifié au rendu, jamais ici.
# svg_markup (puce personnalisée) : ignoré au rendu pour
# liste_numerotee (une puce imagée n'a pas de sens sur une liste
# numérotée, voir _render_list).
return {
"items": ["Premier élément", "Deuxième élément"],
"bold": False,
"italic": False,
"underline": False,
"font_family": "",
"font_size": "",
"line_height": "",
"text_color": "",
"list_style_type": "",
"list_style_position": "outside",
"marker_color": "",
"marker_size": "",
"svg_markup": "",
"item_padding": "6px",
"item_spacing": "",
**BOX_DEFAULTS,
"border": default_border(),
}
if kind == "badge":
# Même esprit que titre/paragraphe (bold/align/color... déjà des
# attributs par élément, pas des choix figés par le moteur) :
# svg_markup/width/border_radius/bold/uppercase vides ou False par
# défaut = comportement historique inchangé (pleine largeur, sans
# icône, casse normale) ; un thème ou le créateur les règle au cas
# par cas (retour utilisateur du 24/09/2026).
return {
"content": "Étiquette",
"svg_markup": "",
"width": "",
"border_radius": "",
"bold": False,
"uppercase": False,
}
if kind == "carte":
# Contenu pur (lettre/repère court + titre + description) — sans
# aucun choix de couleur/forme, laissé au futur système de
# templates (voir consigne du 24/09/2026 : moteur = contenu et
# mécanisme uniquement, jamais de style).
return {"label": "A", "title": "Titre de la carte", "description": "Description de la carte."}
if kind == "row": if kind == "row":
return {"gap": 16, "align": "stretch", "justify": "flex-start"} return {"gap": 16, "align": "stretch", "justify": "flex-start"}
if kind == "quiz": if kind == "quiz":
@@ -86,11 +234,20 @@ def element_default_attributes(kind: str) -> dict[str, Any]:
if kind == "association": if kind == "association":
# Même raison de copie que "quiz" ci-dessus (voir association_config.py). # Même raison de copie que "quiz" ci-dessus (voir association_config.py).
return {**DEFAULT_ASSOCIATION_CONFIG, "pairs": list(DEFAULT_ASSOCIATION_CONFIG["pairs"])} return {**DEFAULT_ASSOCIATION_CONFIG, "pairs": list(DEFAULT_ASSOCIATION_CONFIG["pairs"])}
if kind == "memory":
# Même raison de copie que "quiz"/"association" ci-dessus (voir memory_config.py).
return {**DEFAULT_MEMORY_CONFIG, "cards": list(DEFAULT_MEMORY_CONFIG["cards"])}
if kind == "mots":
# Même raison de copie que "quiz"/"association"/"memory" ci-dessus (voir mots_config.py).
return {**DEFAULT_MOTS_CONFIG, "words": list(DEFAULT_MOTS_CONFIG["words"])}
if kind == "scenario":
# Même raison de copie que ci-dessus (voir scenario_config.py).
return {**DEFAULT_SCENARIO_CONFIG, "scenarios": list(DEFAULT_SCENARIO_CONFIG["scenarios"])}
if kind in MINIGAME_KINDS: if kind in MINIGAME_KINDS:
# Panneau Propriétés minimal (voir PLAN.md §3.5, dernier # Panneau Propriétés minimal (voir PLAN.md §3.5, dernier
# paragraphe : "état par défaut en attendant sa spécification") — # paragraphe : "état par défaut en attendant sa spécification") —
# décision actée : cœur complet + mini-jeux en emplacement # décision actée : cœur complet + mini-jeux en emplacement
# réservé (memory/mots/scenario/zones), formulaires de contenu # réservé (mots/scenario/zones), formulaires de contenu dédiés =
# dédiés = chantier séparé. # chantier séparé.
return {"theme_color": "#ff5f2e"} return {"theme_color": "#ff5f2e"}
return {} return {}
+242 -22
View File
@@ -4,27 +4,38 @@ Catalogue statique des types d'éléments du support de formation : bibliothèqu
groupée par catégorie (panneau gauche de l'éditeur), libellés d'affichage, et groupée par catégorie (panneau gauche de l'éditeur), libellés d'affichage, et
attributs par défaut posés à la création de chaque type. attributs par défaut posés à la création de chaque type.
## `SHAPE_KINDS: tuple[str, ...]`
`("rectangle", "cercle", "triangle", "trait")` — couche de formes libres,
toujours en position absolue (`parent_id = NULL`).
## `CONTENT_KINDS: tuple[str, ...]` ## `CONTENT_KINDS: tuple[str, ...]`
`("titre", "paragraphe", "image", "bouton")` — éléments du flux, peuvent `("titre", "paragraphe", "image", "bouton", "liste_puces",
être top-niveau ou enfants d'une rangée. "liste_numerotee", "badge", "carte")` — éléments du flux, peuvent être
top-niveau ou enfants d'une rangée. `"liste_puces"`/`"liste_numerotee"`
partagent la même structure d'attributs (`items`) ; c'est le `kind`
lui-même qui décide `<ul>` ou `<ol>` au rendu (voir
`document_engine/rendering/render_document_element.py::_render_list`),
jamais un attribut `ordered` redondant. `"carte"` reste du contenu PUR,
sans aucun attribut de style/couleur/forme (décision du 24/09/2026 : le
moteur ne porte que contenu et mécanisme, tout habillage visuel revient
à un futur système de templates). `"badge"` a depuis gagné des
attributs de mise en forme PAR ÉLÉMENT (`svg_markup`/`width`/
`border_radius`/`bold`/`uppercase`, retour utilisateur du 24/09/2026)
— même esprit que `bold`/`align`/`color` déjà présents sur
`"titre"`/`"paragraphe"` : des réglages posés par le créateur ou un
thème au cas par cas, jamais une valeur figée dans le moteur. Voir
aussi le mode SVG inline de `"image"` (`svg_markup`) et la pièce
jointe téléchargeable de `"bouton"` (`attachment_filename`),
ci-dessous.
## `MINIGAME_KINDS: tuple[str, ...]` ## `MINIGAME_KINDS: tuple[str, ...]`
`("quiz", "association", "memory", "mots", "scenario", "zones")` — `"quiz"` `("quiz", "association", "memory", "mots", "scenario", "zones")` —
et `"association"` sont implémentés (voir `quiz_config.py`/ `"quiz"`, `"association"` et `"memory"` sont implémentés (voir
`association_config.py` ci-dessous) ; les 4 autres gardent un panneau `quiz_config.py`/`association_config.py`/`memory_config.py` ci-dessous) ;
Propriétés minimal (emplacement réservé), formulaires de contenu dédiés les 3 autres gardent un panneau Propriétés minimal (emplacement réservé),
hors périmètre de cette passe. formulaires de contenu dédiés hors périmètre de cette passe.
## `ELEMENT_LIBRARY: dict[str, dict[str, Any]]` ## `ELEMENT_LIBRARY: dict[str, dict[str, Any]]`
Bibliothèque affichée dans le panneau gauche, groupée par catégorie Bibliothèque affichée dans le panneau gauche, groupée par catégorie
(`mise_en_page` / `contenu` / `minigames`), chaque entrée portant un (`contenu` / `minigames`), chaque entrée portant un `label` et sa liste
`label` et sa liste de `kinds`. `"row"` n'y apparaît jamais — créé de `kinds`. `"row"` n'y apparaît jamais — créé implicitement par le
implicitement par le moteur de layout, jamais choisi directement dans la moteur de layout, jamais choisi directement dans la bibliothèque.
bibliothèque.
## `ELEMENT_KIND_LABELS: dict[str, str]` ## `ELEMENT_KIND_LABELS: dict[str, str]`
Libellé d'affichage pour chaque `kind`, y compris `"row"` (pour Libellé d'affichage pour chaque `kind`, y compris `"row"` (pour
@@ -33,13 +44,57 @@ l'affichage/debug hors bibliothèque).
## `element_default_attributes(kind: str) -> dict[str, Any]` ## `element_default_attributes(kind: str) -> dict[str, Any]`
Attributs posés à la création d'un élément de ce type (voir Attributs posés à la création d'un élément de ce type (voir
`document_engine/elements/add_document_element.py`). `document_engine/elements/add_document_element.py`).
- **Retour** : un dict d'attributs par défaut, dépendant du `kind` : - **Retour** : un dict d'attributs par défaut, dépendant du `kind` (les
formes (`x/y/width/height/rotation/z_index/fill/stroke/stroke_width/label`), "attributs de boîte partagés" mentionnés ci-dessous — `padding/margin/
texte (`content/style` + `bold/italic/underline/align/color`), background_color/border_radius/width/max_width/height/min_height/
image (`src/alt`), bouton (`label/target`), rangée (`gap/align/justify`), max_height/min_width/box_shadow/opacity/align_self/content_align/
quiz (`DEFAULT_QUIZ_CONFIG`, voir `quiz_config.py`), association border` — sont toujours les mêmes, voir `rendering/box_style.py` :
(`DEFAULT_ASSOCIATION_CONFIG`, voir `association_config.py`), autre tous vides, `False` ou `"none"`/`"stretch"`/`"top"` par défaut =
mini-jeu (`theme_color`), ou `{}` pour un `kind` inconnu. comportement historique inchangé pour le kind qui les gagne ;
`content_align` — alignement vertical du CONTENU dans son bloc, retour
utilisateur du 26/09/2026 — n'est cependant appliqué au rendu QUE par
les kinds dont le conteneur est en colonne (texte, liste, figure d'une
image légendée), jamais par le Bouton, voir `rendering.md`) :
texte (`content/style` + `bold/italic/underline/strikethrough/align/
color/font_family/font_size/line_height/letter_spacing/
text_transform/text_shadow` + les attributs de boîte partagés),
image (`src/alt/svg_markup` — `svg_markup` prend le pas sur `src` au
rendu, voir rendering.md — `object_fit="cover"` et `height="220px"`
par défaut (retour utilisateur du 26/09/2026 : "ce cadre ne devrait
pas changer de taille en fonction de la taille de l'image mais être
fixe et contraindre l'image dedans" — un cadre FIXE, jamais dicté par
la résolution native du fichier importé ; "Taille réelle" reste un
choix explicite possible via le panneau), `aspect_ratio/filter_preset`
vides par défaut, `click_behavior` (`""`/`"link"`/`"lightbox"`) +
`link_url` + `lazy_load` + `caption` : comportement/contenu, pas du
style + les attributs de boîte partagés), bouton (`label/target/
attachment_stored_name/attachment_filename` — la pièce jointe est
indépendante de `target`, réservé à la navigation — + `bold/italic/
text_transform/font_family/font_size/letter_spacing/text_color`
(typo, jamais gérés par box_style.py) + `svg_markup/icon_position
("before"/"after")/icon_size` (icône optionnelle, voir rendering.md)
+ les attributs de boîte partagés), liste à
puces/numérotée (`items`, une liste de chaînes — style sur la LISTE
ENTIÈRE uniquement, jamais par élément individuel ni de sous-listes,
portée actée avec l'utilisateur le 26/09/2026 : `bold/italic/
underline/font_family/font_size/line_height/text_color` (typo) +
`list_style_type` (valide selon le kind, voir rendering.md)/
`list_style_position`/`marker_color`/`marker_size`/`svg_markup`
(puce personnalisée, liste à puces uniquement) + `item_padding`
(`"6px"` par défaut, PAS vide — retour utilisateur explicite : "il
faut un padding de base par élément de liste car y en a pas
aujourd'hui", UNIFORME sur tous les éléments, jamais réglable
individuellement) + `item_spacing` + les attributs de boîte
partagés), badge
(`content/svg_markup/width/border_radius/bold/uppercase` — tous vides
ou `False` par défaut = comportement historique inchangé, voir
rendering.md), carte (`label/title/description`, contenu pur sans
couleur/forme),
rangée (`gap/align/justify`), quiz (`DEFAULT_QUIZ_CONFIG`, voir
`quiz_config.py`), association (`DEFAULT_ASSOCIATION_CONFIG`, voir
`association_config.py`), memory (`DEFAULT_MEMORY_CONFIG`, voir
`memory_config.py`), autre mini-jeu (`theme_color`), ou `{}` pour un
`kind` inconnu.
- **Exceptions** : aucune. - **Exceptions** : aucune.
## `quiz_config.py` — modèle de données du mini-jeu Quiz ## `quiz_config.py` — modèle de données du mini-jeu Quiz
@@ -102,3 +157,168 @@ levée qui ferait échouer tout le reste du mini-jeu). La liste finale est
tronquée à `MAX_PAIRS`. tronquée à `MAX_PAIRS`.
- **Retour** : dict complet (mêmes clés que `DEFAULT_ASSOCIATION_CONFIG`). - **Retour** : dict complet (mêmes clés que `DEFAULT_ASSOCIATION_CONFIG`).
- **Exceptions** : aucune. - **Exceptions** : aucune.
## `memory_config.py` — modèle de données du mini-jeu Memory
Troisième mini-jeu implémenté : l'apprenant retourne des cartes pour
constituer des paires identiques (mode `"paire"`) ou simplement révéler
chaque carte une fois (mode `"single"`, sans appariement — voir
`document_engine/rendering/render_document_element.py`::
`_render_memory_player`). Même convention que `quiz_config.py`.
### `MIN_CARDS`, `MAX_CARDS: int`
Bornes de validation (`2`/`8` cartes DÉFINIES par le créateur — en mode
`"paire"`, le plateau affiche le double, chaque carte étant dupliquée).
`MIN_CARDS` n'est pas imposé par `sanitize_memory_config` (même logique
que `MIN_PAIRS` côté Association) — recommandation pour le panneau
Propriétés, pas une contrainte technique du rendu.
### `CARD_MODES: tuple[str, ...]`
`("paire", "single")`.
### `DEFAULT_MEMORY_CONFIG: dict[str, Any]`
`{"theme_color": "#ff5f2e", "mode": "paire", "cards": []}`.
### `sanitize_memory_config(raw_config: Any) -> dict[str, Any]`
Valide/nettoie une config de memory arbitraire (JSON venu du client) —
jamais ne lève, renvoie toujours un dict COMPLET fusionné sur
`DEFAULT_MEMORY_CONFIG`. `mode` retombe sur `"paire"` s'il n'est pas dans
`CARD_MODES`. Chaque carte de `raw_config["cards"]` est validée
indépendamment (voir `_sanitize_card`/`_sanitize_card_face`, privées) :
chaque face (`recto`/`verso`) a un `image` et un `text` indépendants et
tous deux optionnels, MAIS le `verso` doit avoir au moins l'un des deux
non vide (rien à révéler/apparier sinon) — le `recto`, lui, peut rester
entièrement vide (dos de carte générique "?" par défaut côté rendu). Une
carte invalide est silencieusement supprimée de la liste. La liste finale
est tronquée à `MAX_CARDS`.
- **Retour** : dict complet (mêmes clés que `DEFAULT_MEMORY_CONFIG`).
- **Exceptions** : aucune.
## `mots_config.py` — modèle de données du mini-jeu Mots mêlés
Quatrième mini-jeu implémenté : l'apprenant retrouve chaque mot caché
dans une grille de lettres (horizontalement, verticalement, ou en
diagonale — voir `document_engine/rendering/render_document_element.py`::
`_render_mots_player`, qui construit la grille elle-même). Même
convention que `quiz_config.py`.
### `MIN_WORDS`, `MAX_WORDS: int`
Bornes de validation (`5`/`10` mots). `MIN_WORDS` n'est pas imposé par
`sanitize_mots_config` (même logique que `MIN_PAIRS`/`MIN_CARDS`
côté Association/Memory) — recommandation pour le panneau Propriétés,
pas une contrainte technique du rendu : un seul mot valide reste
accepté, la grille se construit quand même autour de lui.
### `MIN_WORD_LENGTH`, `MAX_WORD_LENGTH: int`
Bornes de longueur d'un mot individuel une fois nettoyé (`2`/`20`
lettres) — un mot trop court n'a pas de sens à chercher, un mot trop
long compliquerait inutilement le calcul de la taille de la grille
(voir `_mots_grid_size_for_words` dans `render_document_element.py`).
### `DEFAULT_MOTS_CONFIG: dict[str, Any]`
`{"theme_color": "#ff5f2e", "words": []}`.
### `sanitize_mots_config(raw_config: Any) -> dict[str, Any]`
Valide/nettoie une config de mots mêlés arbitraire (JSON venu du
client) — jamais ne lève, renvoie toujours un dict COMPLET fusionné sur
`DEFAULT_MOTS_CONFIG`. Chaque mot de `raw_config["words"]` est nettoyé
indépendamment (voir `_sanitize_word`, privée) : mis en MAJUSCULES,
les accents sont retirés (décomposition NFKD + filtrage ASCII, pour que
deux mots qui se croisent sur une même case de la grille puissent
partager exactement la même lettre), seules les lettres sont
conservées. Un mot qui ne contient plus rien d'exploitable (ou moins de
`MIN_WORD_LENGTH` lettres) une fois nettoyé est silencieusement
supprimé de la liste, de même qu'un doublon exact d'un mot déjà retenu
(un mot répété deux fois n'aurait rien de plus à faire trouver). La
liste finale est tronquée à `MAX_WORDS`.
- **Retour** : dict complet (mêmes clés que `DEFAULT_MOTS_CONFIG`).
- **Exceptions** : aucune.
## `scenario_config.py` — modèle de données du mini-jeu Scénario
Cinquième mini-jeu implémenté : l'apprenant lit une situation initiale,
choisit une option, puis découvre la conséquence de SON choix — cette
conséquence peut elle-même mener à de nouveaux choix, et ainsi de suite
(arbre de décision, voir `document_engine/rendering/
render_document_element.py`::`_render_scenario_player`). Aucune notion
de bonne/mauvaise réponse (retour utilisateur du 20/09/2026 : "il n'y a
pas de notion vrai/faux, l'utilisateur observe les conséquences") —
l'apprenant explore simplement les branches jusqu'à une fin. Le
créateur peut définir plusieurs scénarios (plusieurs arbres
indépendants), joués les uns après les autres dans l'ORDRE d'écriture
(jamais mélangés, contrairement à Association/Memory/Mots mêlés). Même
convention resolve_X/sanitize_X que `quiz_config.py`.
**Structure d'un scénario** : `{"title": str, "nodes": [...]}`.
`nodes[0]` est TOUJOURS la situation initiale (racine de l'arbre, jamais
supprimable depuis le panneau Propriétés — voir
`forgeDocScenarioGraphRenderInspector`, static/document/js/
document-editor.js). Chaque nœud est `{"id": str, "text": str,
"x": float, "y": float, "choices": [...]}` — `id` est une référence
STABLE (générée côté client, jamais recalculée côté serveur) vers
laquelle un choix d'un AUTRE nœud peut pointer via son `target_id` ;
`x`/`y` positionnent le nœud sur le canevas du graphe visuel (voir
`forgeDocRenderScenarioTreeModal`) ; un nœud SANS choix est une fin de
branche (état normal, pas une erreur — contrairement à l'ancien modèle
qui exigeait 2 à 4 choix par scénario). Un choix est `{"text": str,
"target_id": str | None}` — `target_id` à `None` signifie "fin de
branche à ce choix".
### `MAX_SCENARIO_CHOICES: int`
Borne technique du nombre de choix par nœud (`4`) — évite une UI
disproportionnée, aucun lien avec une notion de bonne/mauvaise réponse.
Nommée explicitement "SCENARIO" (pas juste `MAX_CHOICES`) pour éviter
toute collision avec la constante de même nom de `quiz_config.py`, qui
borne un concept différent (le nombre de réponses d'une question de
quiz) — `document_engine/__init__.py` ré-exporte l'intégralité de l'API
publique du paquet à plat, un nom générique entrerait en collision.
Contrairement à l'ancien modèle, il n'existe PAS de minimum : un nœud
peut avoir 0 choix (fin de branche), c'est un état valide, pas une
erreur à filtrer.
### `DEFAULT_SCENARIO_CONFIG: dict[str, Any]`
`{"theme_color": "#ff5f2e", "scenarios": []}`.
### `sanitize_scenario_config(raw_config: Any) -> dict[str, Any]`
Valide/nettoie une config de scénario arbitraire (JSON venu du client) —
jamais ne lève, renvoie toujours un dict COMPLET fusionné sur
`DEFAULT_SCENARIO_CONFIG`. Chaque scénario de `raw_config["scenarios"]`
est validé indépendamment (voir `_sanitize_scenario`/
`_sanitize_scenario_node`/`_sanitize_scenario_choice`, privées) : le
`title` doit être non vide, chaque nœud doit avoir un `id` (chaîne non
vide) et un `text` non vide une fois `.strip()`-é (sinon le nœud entier
est supprimé), et ses choix sont tronqués à `MAX_SCENARIO_CHOICES`. Un
`x`/`y` manquant ou invalide (pas un nombre, ou un booléen — `bool`
hérite de `int` en Python) retombe sur un quadrillage en cascade dérivé
de l'INDEX du nœud dans la liste (`_scenario_node_fallback_position`),
jamais `(0, 0)` pour tous les nœuds, ce qui les empilerait exactement au
même endroit sur le graphe visuel. Le scénario entier est supprimé s'il
ne reste plus aucun nœud valide après
nettoyage (il faut au moins la situation initiale). Une fois l'ensemble
des ids valides connu, tout `target_id` qui ne pointe plus vers un nœud
existant (nœud invalide/supprimé) est silencieusement remis à `None`
("fin de branche") plutôt que de faire échouer tout le scénario — même
philosophie que le reste de ce module : ne jamais faire échouer une
structure entière pour une seule référence cassée.
- **Retour** : dict complet (mêmes clés que `DEFAULT_SCENARIO_CONFIG`).
- **Exceptions** : aucune.
## `sanitize_element_attributes.py` — point d'entrée unique de revalidation par kind
Dispatch `kind -> sanitize_X_config` (`_SANITIZERS`, privée) pour les 5
mini-jeux à structure garantie (Quiz/Association/Memory/Mots
mêlés/Scénario) — les autres kinds n'ont pas de sanitizer et sont
renvoyés tels quels. Utilisé à la fois à l'ÉCRITURE
(`routes/document/document_element_update.py`) ET à la LECTURE
(`routes/document/document_edit.py`, `routes/document/document_render.py`)
: sans ce second usage, des attributs stockés dans un schéma devenu
obsolète (ex. Scénario, passé d'une liste plate à un arbre de décision)
atteindraient le panneau Propriétés côté client TELS QUELS, qui suppose
la forme ACTUELLE — bug réel constaté le 21/09/2026, un élément Scénario
créé avant la refonte en arbre faisait planter silencieusement le
panneau Propriétés (`scenario.nodes` inexistant sur l'ancienne forme).
### `sanitize_element_attributes(kind: str, attributes: Any) -> Any`
- **Retour** : `attributes` revalidé si `kind` a un sanitizer, sinon
`attributes` tel quel.
- **Exceptions** : aucune (délègue à des sanitizers qui ne lèvent jamais).
+60
View File
@@ -0,0 +1,60 @@
"""Modèle de données du mini-jeu Memory (voir docs/plan/PLAN.md §3.2) —
l'apprenant retourne des cartes pour constituer des paires identiques
(mode "paire") ou simplement révéler chaque carte une fois (mode
"single", un retournement classique sans appariement). Même convention
resolve_X/sanitize_X que quiz_config.py/association_config.py (aucun
import croisé)."""
from typing import Any
MIN_CARDS = 2
MAX_CARDS = 8
CARD_MODES = ("paire", "single")
DEFAULT_MODE = "paire"
DEFAULT_MEMORY_CONFIG: dict[str, Any] = {
"theme_color": "#ff5f2e",
"mode": DEFAULT_MODE,
"cards": [],
}
def _sanitize_card_face(raw: Any) -> dict[str, str]:
"""Une face de carte (recto ou verso) — image et texte tous deux
optionnels et indépendants (le créateur peut mettre l'un, l'autre, ou
les deux, voir docs/plan/PLAN.md)."""
if not isinstance(raw, dict):
return {"image": "", "text": ""}
return {
"image": str(raw.get("image", "")).strip(),
"text": str(raw.get("text", "")).strip(),
}
def _sanitize_card(raw: Any) -> dict[str, Any] | None:
"""None si la carte est invalide — un verso entièrement vide (ni image
ni texte) n'aurait rien à révéler/apparier, contrairement au recto qui
peut légitimement rester vide (dos de carte générique par défaut)."""
if not isinstance(raw, dict):
return None
recto = _sanitize_card_face(raw.get("recto"))
verso = _sanitize_card_face(raw.get("verso"))
if not verso["image"] and not verso["text"]:
return None
return {"recto": recto, "verso": verso}
def sanitize_memory_config(raw_config: Any) -> dict[str, Any]:
config = dict(DEFAULT_MEMORY_CONFIG)
if not isinstance(raw_config, dict):
return config
theme_color = raw_config.get("theme_color")
if isinstance(theme_color, str) and theme_color:
config["theme_color"] = theme_color
mode = raw_config.get("mode")
config["mode"] = mode if mode in CARD_MODES else DEFAULT_MODE
raw_cards = raw_config.get("cards")
if isinstance(raw_cards, list):
cards = [c for c in (_sanitize_card(item) for item in raw_cards) if c is not None]
config["cards"] = cards[:MAX_CARDS]
return config
+59
View File
@@ -0,0 +1,59 @@
"""Modèle de données du mini-jeu Mots mêlés (voir docs/plan/PLAN.md §3.2)
— l'apprenant retrouve chaque mot caché dans une grille de lettres, placé
horizontalement, verticalement, ou en diagonale (haut-gauche vers
bas-droite, ou haut-droite vers bas-gauche), jamais à l'envers ni en
diagonale inversée (voir document_engine/rendering/render_document_element.py
::_render_mots_player, qui construit la grille elle-même). Même
convention resolve_X/sanitize_X que quiz_config.py/association_config.py/
memory_config.py (aucun import croisé)."""
import unicodedata
from typing import Any
MIN_WORDS = 5
MAX_WORDS = 10
MIN_WORD_LENGTH = 2
MAX_WORD_LENGTH = 20
DEFAULT_MOTS_CONFIG: dict[str, Any] = {
"theme_color": "#ff5f2e",
"words": [],
}
def _sanitize_word(raw: Any) -> str | None:
"""None si, une fois nettoyé, il ne reste aucune lettre exploitable —
filtré par sanitize_mots_config plutôt que de faire échouer toute la
grille, même convention que quiz_config.py::_sanitize_question. Les
accents sont retirés (décomposition NFKD puis filtrage ASCII) : deux
mots qui se croisent sur une même case doivent pouvoir partager
exactement la même lettre, ce qu'un "É" et un "E" ne permettraient
pas."""
if not isinstance(raw, str):
return None
decomposed = unicodedata.normalize("NFKD", raw.upper())
letters = "".join(ch for ch in decomposed if ch.isalpha() and ch.isascii())
if len(letters) < MIN_WORD_LENGTH:
return None
return letters[:MAX_WORD_LENGTH]
def sanitize_mots_config(raw_config: Any) -> dict[str, Any]:
config = dict(DEFAULT_MOTS_CONFIG)
if not isinstance(raw_config, dict):
return config
theme_color = raw_config.get("theme_color")
if isinstance(theme_color, str) and theme_color:
config["theme_color"] = theme_color
raw_words = raw_config.get("words")
if isinstance(raw_words, list):
words: list[str] = []
seen: set[str] = set()
for item in raw_words:
word = _sanitize_word(item)
if word is None or word in seen:
continue
seen.add(word)
words.append(word)
config["words"] = words[:MAX_WORDS]
return config
@@ -0,0 +1,37 @@
"""Point d'entrée UNIQUE de revalidation des attributs d'un élément par
kind — utilisé à la fois à l'ÉCRITURE (routes/document/
document_element_update.py) ET à la LECTURE (routes/document/
document_edit.py, routes/document/document_render.py). Sans ce second
usage en lecture, des attributs stockés dans un schéma devenu obsolète
(ex. le mini-jeu Scénario, passé d'une liste plate à un arbre de
décision) atteindraient le panneau Propriétés côté client TELS QUELS,
qui suppose la forme ACTUELLE — bug réel constaté le 21/09/2026 : un
élément Scénario créé avant la refonte en arbre faisait planter
silencieusement le panneau Propriétés (`scenario.nodes` inexistant sur
l'ancienne forme), sans qu'aucun message n'indique pourquoi."""
from typing import Any
from .association_config import sanitize_association_config
from .memory_config import sanitize_memory_config
from .mots_config import sanitize_mots_config
from .quiz_config import sanitize_quiz_config
from .scenario_config import sanitize_scenario_config
_SANITIZERS = {
"quiz": sanitize_quiz_config,
"association": sanitize_association_config,
"memory": sanitize_memory_config,
"mots": sanitize_mots_config,
"scenario": sanitize_scenario_config,
}
def sanitize_element_attributes(kind: str, attributes: Any) -> Any:
"""Revalide `attributes` selon `kind`, UNIQUEMENT pour les kinds à
structure garantie (les 5 mini-jeux ci-dessus, voir `_SANITIZERS`) —
les autres kinds (texte/forme/image/bouton/rangée) sont de simples
valeurs scalaires sans structure à garantir, renvoyés TELS QUELS
(jamais sanitizés : ce ne sont pas leur responsabilité ici)."""
sanitizer = _SANITIZERS.get(kind)
return sanitizer(attributes) if sanitizer else attributes
+148
View File
@@ -0,0 +1,148 @@
"""Modèle de données du mini-jeu Scénario (voir docs/plan/PLAN.md §3.2) —
l'apprenant lit une situation initiale, choisit une option, et découvre
la conséquence de SON choix ; cette conséquence peut elle-même mener à
de nouveaux choix, et ainsi de suite (arbre de décision, voir
document_engine/rendering/render_document_element.py::
_render_scenario_player). Aucune notion de bonne/mauvaise réponse
(retour utilisateur du 20/09/2026 : "il n'y a pas de notion vrai/faux,
l'utilisateur observe les conséquences") — l'apprenant explore
simplement les branches. Le créateur peut définir plusieurs scénarios
(plusieurs arbres indépendants), joués les uns après les autres dans
l'ordre d'écriture. Même convention resolve_X/sanitize_X que
quiz_config.py/association_config.py/memory_config.py/mots_config.py
(aucun import croisé) : sanitize_scenario_config est pure, ne lève
jamais, et renvoie toujours un dict complet.
Structure d'un scénario : {"title": str, "nodes": [...]}. `nodes[0]` est
TOUJOURS la situation initiale (racine de l'arbre, jamais supprimable
depuis le panneau Propriétés). Chaque nœud est
{"id": str, "text": str, "x": float, "y": float, "choices": [...]} —
`id` est une référence STABLE (générée côté client, jamais recalculée
ici) vers laquelle un choix d'un AUTRE nœud peut pointer via son
`target_id` ; un nœud sans choix est une fin de branche. `x`/`y`
positionnent le nœud sur le canevas du graphe visuel (voir
document_engine/rendering/render_document_element.py::
_render_scenario_player et forgeDocRenderScenarioGraph, static/document/
js/document-editor.js) — une position manquante/invalide retombe sur un
quadrillage en cascade calculé depuis l'INDEX du nœud dans la liste
(_scenario_node_fallback_position), jamais (0, 0) pour tous les nœuds,
ce qui les empilerait exactement au même endroit. Un choix est
{"text": str, "target_id": str | None} — `target_id` à None signifie
"fin de branche à ce choix" (aucun nœud suivant)."""
from typing import Any
MAX_SCENARIO_CHOICES = 4
_NODE_GRID_COLUMNS = 4
_NODE_GRID_STEP_X = 220
_NODE_GRID_STEP_Y = 160
_NODE_GRID_MARGIN = 40
DEFAULT_SCENARIO_CONFIG: dict[str, Any] = {
"theme_color": "#ff5f2e",
"scenarios": [],
}
def _sanitize_scenario_choice(raw: Any) -> dict[str, Any] | None:
"""None si le choix est invalide (texte vide) — filtrée par
_sanitize_scenario_node plutôt que de faire échouer tout le nœud,
même convention que quiz_config.py::_sanitize_question. `target_id`
est vérifié/nettoyé une seconde fois par _sanitize_scenario (une fois
l'ensemble des ids valides du scénario connu), jamais ici."""
if not isinstance(raw, dict):
return None
text = str(raw.get("text", "")).strip()
if not text:
return None
target_id = raw.get("target_id")
return {"text": text, "target_id": target_id if isinstance(target_id, str) and target_id else None}
def _scenario_node_fallback_position(index: int) -> tuple[float, float]:
"""Position par défaut d'un nœud dont x/y est manquant/invalide — un
quadrillage en cascade dérivé de son INDEX dans la liste, jamais
(0, 0) pour tous les nœuds (qui les empilerait exactement au même
endroit, rendant le graphe visuel illisible à la première ouverture
d'un scénario créé avant l'ajout de x/y au modèle, ou d'un nœud
ajouté par un client qui n'enverrait pas encore de position)."""
col = index % _NODE_GRID_COLUMNS
row = index // _NODE_GRID_COLUMNS
return (
_NODE_GRID_MARGIN + col * _NODE_GRID_STEP_X,
_NODE_GRID_MARGIN + row * _NODE_GRID_STEP_Y,
)
def _sanitize_scenario_node(raw: Any, index: int) -> dict[str, Any] | None:
"""None si le nœud est invalide (id absent, ou texte vide) — même
convention que les autres sanitize_X de ce module. Un nœud SANS choix
est parfaitement valide (fin de branche), contrairement à l'ancien
modèle où un scénario exigeait 2 à 4 choix : ici, 0 choix est un état
normal, pas une erreur."""
if not isinstance(raw, dict):
return None
node_id = raw.get("id")
if not isinstance(node_id, str) or not node_id:
return None
text = str(raw.get("text", "")).strip()
if not text:
return None
raw_choices = raw.get("choices")
choices = []
if isinstance(raw_choices, list):
choices = [c for c in (_sanitize_scenario_choice(item) for item in raw_choices) if c is not None]
choices = choices[:MAX_SCENARIO_CHOICES]
fallback_x, fallback_y = _scenario_node_fallback_position(index)
raw_x, raw_y = raw.get("x"), raw.get("y")
x = raw_x if isinstance(raw_x, (int, float)) and not isinstance(raw_x, bool) else fallback_x
y = raw_y if isinstance(raw_y, (int, float)) and not isinstance(raw_y, bool) else fallback_y
return {"id": node_id, "text": text, "x": x, "y": y, "choices": choices}
def _sanitize_scenario(raw: Any) -> dict[str, Any] | None:
"""None si le scénario est invalide (titre vide, ou aucun nœud
valide restant après nettoyage — il faut au moins la situation
initiale). Un choix qui pointait vers un nœud devenu invalide/
supprimé dégrade silencieusement vers `target_id: None` ("fin de
branche") plutôt que de faire échouer tout le scénario — même
philosophie que le reste de ce module : ne jamais lever, ne jamais
faire échouer une structure entière pour une seule référence
cassée."""
if not isinstance(raw, dict):
return None
title = str(raw.get("title", "")).strip()
if not title:
return None
raw_nodes = raw.get("nodes")
if not isinstance(raw_nodes, list):
return None
nodes: list[dict[str, Any]] = []
seen_ids: set[str] = set()
for item in raw_nodes:
node = _sanitize_scenario_node(item, len(nodes))
if node is None or node["id"] in seen_ids:
continue
seen_ids.add(node["id"])
nodes.append(node)
if not nodes:
return None
valid_ids = {n["id"] for n in nodes}
for node in nodes:
node["choices"] = [
{**c, "target_id": c["target_id"] if c["target_id"] in valid_ids else None} for c in node["choices"]
]
return {"title": title, "nodes": nodes}
def sanitize_scenario_config(raw_config: Any) -> dict[str, Any]:
config = dict(DEFAULT_SCENARIO_CONFIG)
if not isinstance(raw_config, dict):
return config
theme_color = raw_config.get("theme_color")
if isinstance(theme_color, str) and theme_color:
config["theme_color"] = theme_color
raw_scenarios = raw_config.get("scenarios")
if isinstance(raw_scenarios, list):
config["scenarios"] = [s for s in (_sanitize_scenario(item) for item in raw_scenarios) if s is not None]
return config
@@ -0,0 +1,23 @@
from db.supports import connect_support, ensure_document_pages_schema
def add_document_page(slug: str, title: str | None = None) -> int:
"""Ajoute une page en fin de la bande d'onglets — titre par défaut
"Page N" (N = position 1-indexée dans la liste actuelle + 1) si
`title` n'est pas fourni, jamais un titre vide (voir
update_document_page.py, qui lui rejette silencieusement un titre
vide au renommage — ici la valeur par défaut ne peut structurellement
pas être vide, donc rien à valider)."""
ensure_document_pages_schema(slug)
conn = connect_support(slug)
max_row = conn.execute("SELECT MAX(order_index) AS m, COUNT(*) AS n FROM _document_pages").fetchone()
order_index = (max_row["m"] or 0) + 1 if max_row["n"] else 0
page_title = title.strip() if title and title.strip() else f"Page {max_row['n'] + 1}"
conn.execute(
"INSERT INTO _document_pages (title, order_index) VALUES (?, ?)",
(page_title, order_index),
)
page_id = int(conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"])
conn.commit()
conn.close()
return page_id
@@ -0,0 +1,16 @@
from db.supports import connect_support, ensure_document_pages_schema
def delete_all_document_pages(slug: str) -> None:
"""Supprime toutes les pages d'un support d'un coup (retour
utilisateur : "une option dans page pour supprimer toute les page
d'un coup") — CASCADE (contrainte FK, voir create_support.py) retire
aussi tous les éléments de contenu du support. Résultat : un support
à 0 page, état volontairement valide (voir list_document_pages.py) ;
l'utilisateur repart d'un éditeur vide comme un support neuf."""
ensure_document_pages_schema(slug)
conn = connect_support(slug)
conn.execute("DELETE FROM _document_elements")
conn.execute("DELETE FROM _document_pages")
conn.commit()
conn.close()
@@ -0,0 +1,18 @@
from db.supports import connect_support, ensure_document_pages_schema
def delete_document_page(slug: str, page_id: int) -> None:
"""Supprime une page — CASCADE (contrainte FK sur les supports créés
après l'ajout des pages ; ensure_document_pages_schema n'en pose pas
pour les anciens, voir son commentaire) retire aussi ses éléments.
Ne refuse JAMAIS ici de supprimer la dernière page restante — cette
règle est un garde-fou métier posé par l'appelant (voir
routes/document/document_page_delete.py), pas une contrainte
structurelle de cette fonction bas niveau (même découpage que
routes/game/screens/screen_delete.py côté jeu)."""
ensure_document_pages_schema(slug)
conn = connect_support(slug)
conn.execute("DELETE FROM _document_elements WHERE page_id = ?", (page_id,))
conn.execute("DELETE FROM _document_pages WHERE id = ?", (page_id,))
conn.commit()
conn.close()
@@ -0,0 +1,11 @@
from typing import Any
from db.supports import connect_support, ensure_document_pages_schema
def get_document_page(slug: str, page_id: int) -> dict[str, Any] | None:
ensure_document_pages_schema(slug)
conn = connect_support(slug)
row = conn.execute("SELECT * FROM _document_pages WHERE id = ?", (page_id,)).fetchone()
conn.close()
return dict(row) if row else None
@@ -0,0 +1,17 @@
from typing import Any
from db.supports import connect_support, ensure_document_pages_schema
def list_document_pages(slug: str) -> list[dict[str, Any]]:
"""Toutes les pages d'un support, triées par order_index — la bande
d'onglets du panneau Propriétés (voir static/document/js/
document-editor.js) et le sélecteur de page du Mode Aperçu en dérivent
directement. Peut renvoyer une liste VIDE (retour utilisateur : un
support neuf, ou vidé via "Supprimer toutes les pages", s'ouvre sans
aucune page — voir routes/document/document_edit.py, qui gère ce cas)."""
ensure_document_pages_schema(slug)
conn = connect_support(slug)
rows = conn.execute("SELECT * FROM _document_pages ORDER BY order_index").fetchall()
conn.close()
return [dict(row) for row in rows]
@@ -0,0 +1,20 @@
from db.supports import connect_support, ensure_document_pages_schema
def move_document_page(slug: str, page_id: int, new_index: int) -> None:
"""Réordonne une page dans la bande d'onglets (glisser-déposer) —
renumérote intégralement order_index sur TOUTES les pages, même
principe que move_document_element (jamais un décalage un par un)."""
ensure_document_pages_schema(slug)
conn = connect_support(slug)
page_ids = [
r["id"]
for r in conn.execute(
"SELECT id FROM _document_pages WHERE id != ? ORDER BY order_index", (page_id,)
).fetchall()
]
page_ids.insert(max(0, min(new_index, len(page_ids))), page_id)
for index, pid in enumerate(page_ids):
conn.execute("UPDATE _document_pages SET order_index = ? WHERE id = ?", (index, pid))
conn.commit()
conn.close()
+112
View File
@@ -0,0 +1,112 @@
# document_engine/pages/
CRUD des pages d'un support de formation (`_document_pages`, voir
`db/supports/create_support.py`) — retour utilisateur du 21/09/2026:
"il faut implémenter un système de page". Un support est désormais
composé de plusieurs pages, chacune portant son propre flux d'éléments
(voir `document_engine/elements/`, filtré par `page_id`).
**Un support peut avoir 0 page** (retour utilisateur du 26/09/2026 :
"l'éditeur ne dois plus etre obliger d'avoir une page active ou créer,
il peut etre ouvert sans aucune page") — `create_support` n'en crée plus
aucune par défaut, et `ensure_document_pages_schema` ne recrée plus
"Page 1" dès que la table est vide (seule exception : la migration
ponctuelle et historique d'un support pré-pages qui avait déjà des
éléments sans `page_id`). `routes/document/document_edit.py` et le
frontend (`static/document/js/document-editor.js`) gèrent explicitement
cet état "aucune page" (pas de page active, canevas vide avec une
invite à en créer une). La garde "jamais supprimer la dernière page" a
été retirée du côté route (voir `delete_all_document_pages` ci-dessous
et `routes/document/document_page_delete.py`) : ce paquet n'a jamais
posé cette contrainte lui-même.
## `add_document_page(slug: str, title: str | None = None) -> int`
Ajoute une page en fin de la bande d'onglets. `title` par défaut :
"Page N" (N = position 1-indexée + 1) si non fourni.
- **Retour** : l'`id` de la nouvelle page.
- **Exceptions** : aucune levée explicitement.
## `list_document_pages(slug: str) -> list[dict[str, Any]]`
Toutes les pages du support, triées par `order_index`.
- **Retour** : liste de dicts (une ligne de table chacun).
- **Exceptions** : aucune.
## `get_document_page(slug: str, page_id: int) -> dict[str, Any] | None`
Récupère une seule page par id.
- **Retour** : le dict de la page, ou `None` si l'id n'existe pas.
- **Exceptions** : aucune.
## `update_document_page(slug: str, page_id: int, title: str) -> None`
Renomme une page. Un titre vide (une fois `.strip()`-é) est
silencieusement ignoré — la page garde son titre précédent plutôt que de
se retrouver sans nom dans la bande d'onglets.
- **Retour** : aucun.
- **Exceptions** : aucune.
## `move_document_page(slug: str, page_id: int, new_index: int) -> None`
Réordonne une page dans la bande d'onglets (glisser-déposer) —
renumérote intégralement `order_index` sur toutes les pages, même
principe que `document_engine.move_document_element` (jamais un
décalage un par un).
- **Retour** : aucun.
- **Exceptions** : aucune.
## `set_document_page_vertical_align(slug: str, page_id: int, vertical_align: str) -> None`
Règle l'alignement vertical du CONTENU d'une page (`justify-content` de
`.docPageContent`, voir `static/document/document-editor.css`) —
réglable depuis le panneau Propriétés quand l'onglet "Pages" de
l'éditeur est actif (retour utilisateur du 24/09/2026 : "quand je suis
sur l'onglet page, dans les propriétés s'affiche l'option de
l'alignement de la page"). Une valeur hors de `VERTICAL_ALIGNS`
(`"top"`/`"center"`/`"bottom"`) retombe silencieusement sur `"top"`,
même philosophie défensive que `update_document_page` pour un titre
vide.
- **Retour** : aucun.
- **Exceptions** : aucune.
### `VERTICAL_ALIGNS: tuple[str, ...]`
`("top", "center", "bottom")` — valeurs valides de `vertical_align`,
`"top"` étant la valeur par défaut posée en base (voir
`db/supports/create_support.py`/`ensure_document_pages_schema.py`).
## `replace_document_content(slug: str, seed_pages: list[dict[str, Any]]) -> None`
Remplace TOUT le contenu du support par `seed_pages` — utilisée
UNIQUEMENT quand le créateur choisit "utiliser le contenu du modèle" en
appliquant un thème (voir `routes/document/document_theme_apply.py` et
`document_engine/themes/`), jamais appelée sans confirmation explicite
côté client (action destructive, irréversible côté serveur). `seed_pages`
est une liste de pages, chaque page un dict
`{"vertical_align": "top"|"center"|"bottom", "blocks": [...]}`
(`vertical_align` optionnel, retombe sur `"top"`) ; chaque bloc de
`blocks` est `{"kind", "attributes", "children"}` (`children` optionnel,
uniquement pour un bloc `kind="row"` — un seul niveau de profondeur,
comme le moteur de rangées lui-même). Les attributs fournis sont
fusionnés sur `element_default_attributes(kind)`, jamais un remplacement
brut. Les nouvelles pages sont créées AVANT que les anciennes soient
supprimées (jamais l'inverse) : passer par zéro page, même brièvement,
déclenche le filet de sécurité de `ensure_document_pages_schema` (un
support a toujours au moins une page), qui recréerait une "Page 1" vide
parasite.
- **Retour** : aucun.
- **Exceptions** : aucune levée explicitement.
## `delete_document_page(slug: str, page_id: int) -> None`
Supprime une page ET ses éléments (`DELETE FROM _document_elements
WHERE page_id = ?` explicite — la contrainte `FOREIGN KEY ... ON DELETE
CASCADE` n'existe que pour les supports créés après l'ajout des pages,
voir `db/supports/ensure_document_pages_schema.py` pour les anciens).
Ne refuse JAMAIS de supprimer la dernière page restante — un support à
0 page est un état valide (voir plus haut).
- **Retour** : aucun.
- **Exceptions** : aucune.
## `delete_all_document_pages(slug: str) -> None`
Supprime TOUTES les pages du support d'un coup, et tous leurs éléments
de contenu avec elles (retour utilisateur : "une option dans page pour
supprimer toute les page d'un coup") — action destructive et
irréversible côté serveur, jamais appelée sans confirmation explicite
côté client (voir `static/document/js/document-editor.js`,
`forgeDocDeleteAllPages`). Le support se retrouve à 0 page, exactement
comme un support neuf.
- **Retour** : aucun.
- **Exceptions** : aucune.
@@ -0,0 +1,54 @@
from typing import Any
from ..elements.add_document_element import add_document_element
from ..elements.update_document_element_attributes import update_document_element_attributes
from ..labels.element_kind_labels import element_default_attributes
from .add_document_page import add_document_page
from .delete_document_page import delete_document_page
from .list_document_pages import list_document_pages
from .set_document_page_vertical_align import set_document_page_vertical_align
def replace_document_content(slug: str, seed_pages: list[dict[str, Any]]) -> None:
"""Remplace TOUT le contenu du support par `seed_pages` — utilisé
UNIQUEMENT quand le créateur choisit explicitement "utiliser le
contenu du modèle" en appliquant un thème (voir routes/document/
document_theme_apply.py, jamais appelée sans confirmation explicite
côté client : action destructive, irréversible côté serveur).
`seed_pages` est une liste de pages, chaque page un dict
`{"vertical_align": "top"|"center"|"bottom", "blocks": [...]}`
(`vertical_align` optionnel, retombe sur "top" — voir
set_document_page_vertical_align.VERTICAL_ALIGNS) ; chaque bloc de
`blocks` est `{"kind": str, "attributes": dict, "children": [...]}`
(`children` optionnel, uniquement pour un bloc `kind="row"` — chaque
enfant a la même forme `{"kind", "attributes"}`, sans petit-enfant :
le moteur de rangées ne descend jamais à plus d'un niveau, voir
document_engine/rendering/render_document_element.py::_render_row).
Les attributs fournis sont FUSIONNÉS sur
element_default_attributes(kind) (jamais un remplacement brut) pour
rester valides même si `seed_pages` n'en précise qu'une partie."""
# Les nouvelles pages sont créées AVANT de supprimer les anciennes
# (jamais l'inverse) : ça évite que le support affiche un état "0 page"
# transitoire pendant le remplacement (même si 0 page est désormais un
# état par ailleurs valide, voir list_document_pages.py — ce n'est
# qu'une question d'ordre d'écriture ici, plus un filet de sécurité).
old_page_ids = [page["id"] for page in list_document_pages(slug)]
for seed_page in seed_pages:
page_id = add_document_page(slug)
vertical_align = seed_page.get("vertical_align")
if vertical_align:
set_document_page_vertical_align(slug, page_id, vertical_align)
for block in seed_page.get("blocks", []):
_add_seed_block(slug, page_id, block, parent_id=None)
for old_page_id in old_page_ids:
delete_document_page(slug, old_page_id)
def _add_seed_block(slug: str, page_id: int, block: dict[str, Any], parent_id: int | None) -> None:
kind = block["kind"]
element_id = add_document_element(slug, kind, page_id=page_id, parent_id=parent_id)
attributes = {**element_default_attributes(kind), **block.get("attributes", {})}
update_document_element_attributes(slug, element_id, attributes)
for child in block.get("children", []):
_add_seed_block(slug, page_id, child, parent_id=element_id)
@@ -0,0 +1,20 @@
from db.supports import connect_support, ensure_document_pages_schema
VERTICAL_ALIGNS = ("top", "center", "bottom")
def set_document_page_vertical_align(slug: str, page_id: int, vertical_align: str) -> None:
"""Règle l'alignement vertical du CONTENU d'une page (`justify-content`
de `.docPageContent`, voir static/document/document-editor.css) —
réglable depuis le panneau Propriétés quand l'onglet "Pages" est actif
(retour utilisateur du 24/09/2026), jamais un attribut par élément (ça
concerne la page entière, pas un bloc de contenu particulier). Une
valeur hors de `VERTICAL_ALIGNS` retombe silencieusement sur "top"
(état par défaut) plutôt que de lever — même philosophie défensive que
`update_document_page` pour un titre vide."""
ensure_document_pages_schema(slug)
clean_align = vertical_align if vertical_align in VERTICAL_ALIGNS else "top"
conn = connect_support(slug)
conn.execute("UPDATE _document_pages SET vertical_align = ? WHERE id = ?", (clean_align, page_id))
conn.commit()
conn.close()
@@ -0,0 +1,17 @@
from db.supports import connect_support, ensure_document_pages_schema
def update_document_page(slug: str, page_id: int, title: str) -> None:
"""Renomme une page — un titre vide (une fois `.strip()`-é) est
silencieusement ignoré (la page garde son titre précédent) plutôt que
de la faire disparaître de la bande d'onglets, même philosophie que
les sanitize_X_config de document_engine/labels/ : jamais un état
structurel invalide."""
ensure_document_pages_schema(slug)
clean_title = title.strip()
if not clean_title:
return
conn = connect_support(slug)
conn.execute("UPDATE _document_pages SET title = ? WHERE id = ?", (clean_title, page_id))
conn.commit()
conn.close()
+133
View File
@@ -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,9 +1,11 @@
import html as html_lib import html as html_lib
import json import json
import random import random
import urllib.parse
from typing import Any from typing import Any
_SHAPE_TAGS = {"rectangle": "rect", "cercle": "circle", "trait": "line"} from .box_style import render_box_style, render_content_align
from .sanitize_svg_markup import sanitize_svg_markup
def render_document(elements: list[dict[str, Any]]) -> str: def render_document(elements: list[dict[str, Any]]) -> str:
@@ -39,40 +41,6 @@ def _render_row(el: dict[str, Any], children_by_parent: dict[int | None, list[di
return f'<div class="docRow" data-element-id="{el["id"]}" data-kind="row" style="{style}">{inner}</div>' return f'<div class="docRow" data-element-id="{el["id"]}" data-kind="row" style="{style}">{inner}</div>'
def _render_shape(el: dict[str, Any], _children_by_parent: dict[int | None, list[dict[str, Any]]]) -> str:
a = el["attributes"]
x, y, width, height = a["x"], a["y"], a["width"], a["height"]
rotation, z_index = a.get("rotation", 0), a.get("z_index", 1)
fill = html_lib.escape(str(a.get("fill", "#ff5f2e")))
stroke = html_lib.escape(str(a.get("stroke", "#232a38")))
stroke_width = a.get("stroke_width", 2)
label = html_lib.escape(str(a.get("label", "")))
wrap_style = (
f"position:absolute; left:{x}px; top:{y}px; width:{width}px; height:{height}px; "
f"transform:rotate({rotation}deg); z-index:{z_index};"
)
kind = el["kind"]
stroke_attrs = f'stroke="{stroke}" stroke-width="{stroke_width}"'
if kind == "triangle":
points = f"{width / 2},0 {width},{height} 0,{height}"
shape_svg = f'<polygon points="{points}" fill="{fill}" {stroke_attrs}></polygon>'
else:
tag = _SHAPE_TAGS.get(kind, "rect")
if tag == "circle":
cx, cy, r = width / 2, height / 2, min(width, height) / 2
shape_svg = f'<circle cx="{cx}" cy="{cy}" r="{r}" fill="{fill}" {stroke_attrs}></circle>'
elif tag == "line":
shape_svg = f'<line x1="0" y1="{height / 2}" x2="{width}" y2="{height / 2}" {stroke_attrs}></line>'
else:
shape_svg = f'<rect width="{width}" height="{height}" fill="{fill}" {stroke_attrs}></rect>'
label_attr = f' aria-label="{label}"' if label else ' aria-hidden="true"'
return (
f'<div class="docShape" data-element-id="{el["id"]}" data-kind="{kind}" style="{wrap_style}"{label_attr}>'
f'<svg width="{width}" height="{height}" viewBox="0 0 {width} {height}">{shape_svg}</svg>'
f"</div>"
)
_STYLE_PRESETS = { _STYLE_PRESETS = {
"titre1": ("clamp(1.6rem,4vw,2rem)", 800, 1.15), "titre1": ("clamp(1.6rem,4vw,2rem)", 800, 1.15),
"titre2": ("1.3rem", 800, 1.25), "titre2": ("1.3rem", 800, 1.25),
@@ -85,29 +53,337 @@ def _render_text(el: dict[str, Any], _children_by_parent: dict[int | None, list[
a = el["attributes"] a = el["attributes"]
content = html_lib.escape(str(a.get("content", ""))) content = html_lib.escape(str(a.get("content", "")))
style_name = a.get("style", "paragraphe") style_name = a.get("style", "paragraphe")
font_size, base_weight, line_height = _STYLE_PRESETS.get(style_name, _STYLE_PRESETS["paragraphe"]) preset_font_size, base_weight, preset_line_height = _STYLE_PRESETS.get(style_name, _STYLE_PRESETS["paragraphe"])
weight = 800 if a.get("bold") else base_weight weight = 800 if a.get("bold") else base_weight
font_style = "italic" if a.get("italic") else "normal" font_style = "italic" if a.get("italic") else "normal"
text_decoration = "underline" if a.get("underline") else "none"
# underline/strikethrough se combinent (text-decoration-line accepte
# plusieurs valeurs) — retour utilisateur du 26/09/2026 : "barré"
# manquait à côté du souligné déjà existant.
decoration_parts = []
if a.get("underline"):
decoration_parts.append("underline")
if a.get("strikethrough"):
decoration_parts.append("line-through")
text_decoration = " ".join(decoration_parts) if decoration_parts else "none"
align = html_lib.escape(str(a.get("align", "left"))) align = html_lib.escape(str(a.get("align", "left")))
color = html_lib.escape(str(a.get("color", "var(--forge-text)"))) color = html_lib.escape(str(a.get("color", "var(--forge-text)")))
# font_size/line_height : vides par défaut = valeurs du préréglage
# `style` (titre1/titre2/paragraphe/légende) inchangées ; une valeur
# explicite les remplace SANS changer `weight` (qui reste piloté par
# le préréglage + `bold`).
font_size = html_lib.escape(str(a.get("font_size", "")).strip()) or preset_font_size
line_height = html_lib.escape(str(a.get("line_height", "")).strip()) or str(preset_line_height)
style = ( style = (
f"font-size:{font_size}; font-weight:{weight}; line-height:{line_height}; " f"font-size:{font_size}; font-weight:{weight}; line-height:{line_height}; "
f"font-style:{font_style}; text-decoration:{text_decoration}; text-align:{align}; color:{color};" f"font-style:{font_style}; text-decoration:{text_decoration}; text-align:{align}; color:{color};"
) )
text_transform = str(a.get("text_transform", "none"))
if text_transform and text_transform != "none":
style += f" text-transform:{html_lib.escape(text_transform)};"
font_family = str(a.get("font_family", "")).strip()
if font_family:
style += f" font-family:{html_lib.escape(font_family)};"
letter_spacing = str(a.get("letter_spacing", "")).strip()
if letter_spacing:
style += f" letter-spacing:{html_lib.escape(letter_spacing)};"
text_shadow = str(a.get("text_shadow", "")).strip()
if text_shadow:
style += f" text-shadow:{html_lib.escape(text_shadow)};"
# max_width (ex. "60ch", "480px" — retour utilisateur du 24/09/2026 :
# un paragraphe doit pouvoir rester plus étroit que la page, sans
# dépendre d'une rangée qui en partagerait la largeur avec un frère)
# fait maintenant partie des attributs de boîte partagés
# (render_box_style), jamais géré ici en double.
box_style = render_box_style(a)
if box_style:
style += f" {box_style}"
content_align = render_content_align(a)
if content_align:
style += f" {content_align}"
return f'<div class="docText" data-element-id="{el["id"]}" data-kind="{el["kind"]}" style="{style}">{content}</div>' return f'<div class="docText" data-element-id="{el["id"]}" data-kind="{el["kind"]}" style="{style}">{content}</div>'
_IMAGE_OBJECT_FITS = ("cover", "contain", "fill")
_IMAGE_FILTERS = {
"grayscale": "grayscale(1)",
"sepia": "sepia(0.8)",
"blur": "blur(3px)",
}
def _image_extra_style(a: dict[str, Any]) -> str:
"""Déclarations CSS spécifiques à l'image (`object-fit`/`aspect-ratio`/
`filter`) — jamais dans `box_style.py` (partagé), qui ne connaît que
des attributs communs à plusieurs kinds."""
parts = []
object_fit = str(a.get("object_fit", ""))
if object_fit in _IMAGE_OBJECT_FITS:
parts.append(f"object-fit:{object_fit};")
aspect_ratio = str(a.get("aspect_ratio", "")).strip()
if aspect_ratio:
parts.append(f"aspect-ratio:{html_lib.escape(aspect_ratio)};")
filter_value = _IMAGE_FILTERS.get(str(a.get("filter_preset", "")))
if filter_value:
parts.append(f"filter:{filter_value};")
return " ".join(parts)
def _render_image(el: dict[str, Any], _children_by_parent: dict[int | None, list[dict[str, Any]]]) -> str: def _render_image(el: dict[str, Any], _children_by_parent: dict[int | None, list[dict[str, Any]]]) -> str:
a = el["attributes"] a = el["attributes"]
src = html_lib.escape(str(a.get("src", ""))) click_behavior = str(a.get("click_behavior", ""))
alt = html_lib.escape(str(a.get("alt", ""))) link_url = str(a.get("link_url", "")).strip()
if not src: caption = str(a.get("caption", "")).strip()
return ( # render_box_style (padding/margin/fond/bordure/largeur/position du
f'<div class="docImage docImagePlaceholder" data-element-id="{el["id"]}" data-kind="image">' # bloc, dont align-self) doit se poser sur l'élément RÉELLEMENT
f"Image — aucun fichier choisi</div>" # top-niveau — celui qui est l'enfant direct du flex-column de la
# page (voir .docPageContent, static/document/document-editor.css) —
# jamais sur l'<img>/<div> interne dès qu'une légende ou un
# comportement au clic l'enveloppe : un align-self posé sur un
# DESCENDANT du flex-item n'a strictement aucun effet côté CSS (bug
# réel constaté le 26/09/2026 : "la position de bloc ne fonctionne
# pas sur l'image"). has_wrapper détermine qui, de l'image elle-même
# ou de son enveloppe, est ce top-niveau.
has_wrapper = bool(caption) or (click_behavior == "link" and link_url) or click_behavior == "lightbox"
box_style = render_box_style(a)
media_style = " ".join(p for p in (_image_extra_style(a), "" if has_wrapper else box_style) if p)
media_style_attr = f' style="{media_style}"' if media_style else ""
loading_attr = ' loading="lazy"' if a.get("lazy_load") else ""
svg_markup = str(a.get("svg_markup", "")).strip()
if svg_markup:
# Contenu vectoriel dessiné/collé par le créateur plutôt qu'un
# fichier hébergé — prioritaire sur `src` (voir
# element_kind_labels.element_default_attributes). Nettoyé à
# CHAQUE rendu (jamais seulement à l'écriture) par sanitize_svg_markup,
# même défense en profondeur que html.escape sur les autres kinds.
sanitized = sanitize_svg_markup(svg_markup)
media = (
f'<div class="docImage" data-element-id="{el["id"]}" data-kind="image"{media_style_attr}>{sanitized}</div>'
) )
return f'<img class="docImage" data-element-id="{el["id"]}" data-kind="image" src="{src}" alt="{alt}">' else:
src = html_lib.escape(str(a.get("src", "")))
alt = html_lib.escape(str(a.get("alt", "")))
if not src:
media = (
f'<div class="docImage docImagePlaceholder" data-element-id="{el["id"]}" '
f'data-kind="image"{media_style_attr}>Image — aucun fichier choisi</div>'
)
else:
media = (
f'<img class="docImage" data-element-id="{el["id"]}" data-kind="image" '
f'src="{src}" alt="{alt}"{media_style_attr}{loading_attr}>'
)
# Comportement au clic (mutuellement exclusif, voir panneau
# Propriétés) — "lien" ouvre une URL externe dans un nouvel onglet
# (jamais dans l'éditeur lui-même), "plein écran" ouvre un aperçu
# agrandi géré côté client (voir static/document/js/
# document-editor.js::forgeDocOpenImageLightbox), tous deux
# UNIQUEMENT actifs en Mode Aperçu (même principe que les mini-jeux
# et la pièce jointe d'un bouton). Reçoit le style de bloc UNIQUEMENT
# s'il n'y a pas de légende par-dessus (sinon c'est elle, plus
# englobante encore, qui le reçoit juste plus bas).
if click_behavior == "link" and link_url:
href = html_lib.escape(link_url)
wrapper_style_attr = f' style="{box_style}"' if (box_style and not caption) else ""
media = (
f'<a class="docImageLink" href="{href}" target="_blank" '
f'rel="noopener noreferrer"{wrapper_style_attr}>{media}</a>'
)
elif click_behavior == "lightbox":
wrapper_style_attr = f' style="{box_style}"' if (box_style and not caption) else ""
media = f'<div class="docImageLightboxTrigger"{wrapper_style_attr}>{media}</div>'
if caption:
# render_content_align (retour utilisateur du 26/09/2026 :
# "je peux augmenter la hauteur d'un conteneur mais pas
# l'alignement vertical à l'intérieur") n'a de sens ici QUE pour
# la figure (conteneur flex-colonne à plusieurs enfants réels —
# image + légende) : jamais sur l'<img> seul ni sur les
# enveloppes lien/plein écran, qui ne sont pas des conteneurs
# flex-colonne à plusieurs enfants.
figure_style = " ".join(p for p in (box_style, render_content_align(a)) if p)
figure_style_attr = f' style="{figure_style}"' if figure_style else ""
media = (
f'<figure class="docImageFigure"{figure_style_attr}>{media}'
f'<figcaption class="docImageCaption">{html_lib.escape(caption)}</figcaption></figure>'
)
return media
_LIST_STYLE_TYPES = {
"liste_puces": ("disc", "circle", "square", "none"),
"liste_numerotee": (
"decimal",
"decimal-leading-zero",
"lower-roman",
"upper-roman",
"lower-alpha",
"upper-alpha",
"none",
),
}
def _list_text_style(a: dict[str, Any]) -> list[str]:
"""Typographie de la liste ENTIÈRE (jamais par élément individuel,
voir element_kind_labels.py — portée actée avec l'utilisateur)."""
parts = []
if a.get("bold"):
parts.append("font-weight:700;")
if a.get("italic"):
parts.append("font-style:italic;")
if a.get("underline"):
parts.append("text-decoration:underline;")
font_family = str(a.get("font_family", "")).strip()
if font_family:
parts.append(f"font-family:{html_lib.escape(font_family)};")
font_size = str(a.get("font_size", "")).strip()
if font_size:
parts.append(f"font-size:{html_lib.escape(font_size)};")
line_height = str(a.get("line_height", "")).strip()
if line_height:
parts.append(f"line-height:{html_lib.escape(line_height)};")
text_color = str(a.get("text_color", "")).strip()
if text_color:
parts.append(f"color:{html_lib.escape(text_color)};")
return parts
def _list_marker_style(a: dict[str, Any], kind: str) -> list[str]:
"""Puces/numéros — `marker_color`/`marker_size` passent par des
PROPRIÉTÉS PERSONNALISÉES CSS (héritées jusqu'au pseudo-élément
`::marker` de chaque <li>, voir .docList li::marker dans
document-editor.css) : un style inline posé sur le <ul>/<ol> ne peut
pas cibler directement le `::marker` de ses enfants autrement."""
parts = []
list_style_type = str(a.get("list_style_type", ""))
if list_style_type in _LIST_STYLE_TYPES.get(kind, ()):
parts.append(f"list-style-type:{list_style_type};")
position_inside = str(a.get("list_style_position", "outside")) == "inside"
if position_inside:
parts.append("list-style-position:inside;")
# Retour utilisateur du 26/09/2026 : "si j'enlève les puces ou que les
# puces se mettent à l'intérieur, il reste un espace devant la liste,
# cet espace doit être supprimé" — le padding-left:1.4em par défaut
# (document-editor.css, .docList) réserve la place d'une puce
# EXTÉRIEURE ; il n'a plus lieu d'être dès que la puce n'est plus là
# ("none") ou qu'elle rejoint le flux du texte ("inside").
if list_style_type == "none" or position_inside:
parts.append("padding-left:0;")
marker_color = str(a.get("marker_color", "")).strip()
if marker_color:
parts.append(f"--doc-marker-color:{html_lib.escape(marker_color)};")
marker_size = str(a.get("marker_size", "")).strip()
if marker_size:
parts.append(f"--doc-marker-size:{html_lib.escape(marker_size)};")
# Puce personnalisée (image SVG) : liste à puces UNIQUEMENT, une
# puce imagée n'a pas de sens sur une liste numérotée. list-style-image
# prime visuellement sur list-style-type dès qu'il est posé (aucun
# conflit à gérer entre les deux).
svg_markup = str(a.get("svg_markup", "")).strip() if kind == "liste_puces" else ""
if svg_markup:
sanitized = sanitize_svg_markup(svg_markup)
encoded = urllib.parse.quote(sanitized)
parts.append(f'list-style-image:url("data:image/svg+xml,{encoded}");')
return parts
def _list_item_style(a: dict[str, Any]) -> list[str]:
"""`item_padding`/`item_spacing` s'appliquent à CHAQUE <li>, jamais au
conteneur <ul>/<ol> lui-même — même mécanisme de propriété
personnalisée CSS héritée que `_list_marker_style` ci-dessus (voir
.docList li dans document-editor.css). Une valeur UNIFORME partagée
par tous les éléments (retour utilisateur du 26/09/2026), jamais
réglable par élément individuel."""
parts = []
item_padding = str(a.get("item_padding", "")).strip()
if item_padding:
parts.append(f"--doc-item-padding:{html_lib.escape(item_padding)};")
item_spacing = str(a.get("item_spacing", "")).strip()
if item_spacing:
parts.append(f"--doc-item-spacing:{html_lib.escape(item_spacing)};")
return parts
def _render_list(el: dict[str, Any], _children_by_parent: dict[int | None, list[dict[str, Any]]]) -> str:
"""Liste à puces (<ul>) ou numérotée (<ol>) — le kind lui-même décide
la balise, pas un attribut "ordered" séparé (voir
element_kind_labels.element_default_attributes). Une "items" vide
rend une liste vide plutôt qu'un placeholder : contrairement à une
image sans fichier, une liste sans élément n'a rien d'anormal à
afficher (le créateur vient peut-être de tout supprimer avant d'en
retaper un)."""
a = el["attributes"]
kind = el["kind"]
items = a.get("items", [])
tag = "ol" if kind == "liste_numerotee" else "ul"
items_html = "".join(f"<li>{html_lib.escape(str(item))}</li>" for item in items)
style = " ".join(
_list_text_style(a)
+ _list_marker_style(a, kind)
+ _list_item_style(a)
+ [render_box_style(a), render_content_align(a)]
)
style = style.strip()
style_attr = f' style="{style}"' if style else ""
return f'<{tag} class="docList" data-element-id="{el["id"]}" data-kind="{kind}"{style_attr}>{items_html}</{tag}>'
_BUTTON_TEXT_TRANSFORMS = ("uppercase", "lowercase", "capitalize")
def _button_text_style(a: dict[str, Any]) -> list[str]:
"""Déclarations de typographie propres au bouton (jamais dans
box_style.py, partagé avec d'autres kinds qui n'ont pas tous une
notion de texte)."""
parts = []
if a.get("bold"):
# 800 (jamais 700, déjà le poids par défaut du CSS de base) :
# "gras" ne fait que RENFORCER le poids existant, jamais
# l'affaiblir — aucun bouton déjà créé ne change d'apparence tant
# que cette case n'est pas cochée explicitement.
parts.append("font-weight:800;")
if a.get("italic"):
parts.append("font-style:italic;")
text_transform = str(a.get("text_transform", "none"))
if text_transform in _BUTTON_TEXT_TRANSFORMS:
parts.append(f"text-transform:{text_transform};")
font_family = str(a.get("font_family", "")).strip()
if font_family:
parts.append(f"font-family:{html_lib.escape(font_family)};")
font_size = str(a.get("font_size", "")).strip()
if font_size:
parts.append(f"font-size:{html_lib.escape(font_size)};")
letter_spacing = str(a.get("letter_spacing", "")).strip()
if letter_spacing:
parts.append(f"letter-spacing:{html_lib.escape(letter_spacing)};")
text_color = str(a.get("text_color", "")).strip()
if text_color:
parts.append(f"color:{html_lib.escape(text_color)};")
return parts
def _button_icon_html(a: dict[str, Any]) -> str:
svg_markup = str(a.get("svg_markup", "")).strip()
if not svg_markup:
return ""
icon_size = str(a.get("icon_size", "")).strip()
size_style = (
f' style="width:{html_lib.escape(icon_size)}; height:{html_lib.escape(icon_size)};"' if icon_size else ""
)
return f'<span class="docButtonIcon"{size_style}>{sanitize_svg_markup(svg_markup)}</span>'
def _render_button(el: dict[str, Any], _children_by_parent: dict[int | None, list[dict[str, Any]]]) -> str: def _render_button(el: dict[str, Any], _children_by_parent: dict[int | None, list[dict[str, Any]]]) -> str:
@@ -115,9 +391,70 @@ def _render_button(el: dict[str, Any], _children_by_parent: dict[int | None, lis
label = html_lib.escape(str(a.get("label", "Bouton"))) label = html_lib.escape(str(a.get("label", "Bouton")))
target = html_lib.escape(str(a.get("target", ""))) target = html_lib.escape(str(a.get("target", "")))
target_attr = f' data-target="{target}"' if target else "" target_attr = f' data-target="{target}"' if target else ""
# `data-attachment-filename` sert UNIQUEMENT de marqueur mécanique : un
# fichier a bien été joint (voir routes/document/
# document_element_upload_attachment.py). L'URL de téléchargement
# elle-même n'est jamais construite ici (ce renderer ne connaît pas le
# slug du support) — static/document/js/document-editor.js l'assemble
# à partir de `data-element-id` + FORGE_DOCUMENT.slug, même principe
# que le reste des appels AJAX de l'éditeur.
attachment_filename = html_lib.escape(str(a.get("attachment_filename", "")))
attachment_attr = f' data-attachment-filename="{attachment_filename}"' if attachment_filename else ""
style = " ".join(_button_text_style(a) + [render_box_style(a)]).strip()
style_attr = f' style="{style}"' if style else ""
icon_html = _button_icon_html(a)
label_span = f'<span class="docButtonLabel">{label}</span>'
inner = (
f"{label_span}{icon_html}" if str(a.get("icon_position", "before")) == "after" else f"{icon_html}{label_span}"
)
return ( return (
f'<button type="button" class="docButton" data-element-id="{el["id"]}" data-kind="bouton"{target_attr}>' f'<button type="button" class="docButton" data-element-id="{el["id"]}" '
f"{label}</button>" f'data-kind="bouton"{target_attr}{attachment_attr}{style_attr}>{inner}</button>'
)
def _render_badge(el: dict[str, Any], _children_by_parent: dict[int | None, list[dict[str, Any]]]) -> str:
a = el["attributes"]
content = html_lib.escape(str(a.get("content", "")))
svg_markup = str(a.get("svg_markup", "")).strip()
icon_html = f'<span class="docBadgeIcon">{sanitize_svg_markup(svg_markup)}</span>' if svg_markup else ""
style_parts = []
width = str(a.get("width", "")).strip()
if width:
# Fixer une largeur implique de ne plus s'étirer sur toute la
# largeur de .docPageContent (comportement par défaut d'un enfant
# flex en colonne, voir static/document/document-editor.css) —
# les deux vont toujours ensemble, jamais l'un sans l'autre.
style_parts.append(f"align-self:flex-start; width:{html_lib.escape(width)};")
border_radius = str(a.get("border_radius", "")).strip()
if border_radius:
style_parts.append(f"border-radius:{html_lib.escape(border_radius)};")
if a.get("bold"):
style_parts.append("font-weight:800;")
if a.get("uppercase"):
style_parts.append("text-transform:uppercase;")
style_attr = f' style="{" ".join(style_parts)}"' if style_parts else ""
return (
f'<div class="docBadge" data-element-id="{el["id"]}" data-kind="badge"{style_attr}>{icon_html}{content}</div>'
)
def _render_carte(el: dict[str, Any], _children_by_parent: dict[int | None, list[dict[str, Any]]]) -> str:
a = el["attributes"]
label = html_lib.escape(str(a.get("label", "")))
title = html_lib.escape(str(a.get("title", "")))
description = html_lib.escape(str(a.get("description", "")))
return (
f'<div class="docCard" data-element-id="{el["id"]}" data-kind="carte">'
f'<div class="docCardLabel">{label}</div>'
f'<div class="docCardTitle">{title}</div>'
f'<div class="docCardDescription">{description}</div>'
f"</div>"
) )
@@ -221,11 +558,15 @@ def _render_association_player(config: dict[str, Any]) -> str:
f'<div class="docAssocColumn docAssocColumnRight"></div>' f'<div class="docAssocColumn docAssocColumnRight"></div>'
f"</div>" f"</div>"
f'<div class="docAssocFeedback"></div>' f'<div class="docAssocFeedback"></div>'
f"</div>" # Le plateau reste affiché une fois toutes les paires trouvées
f'<div class="docAssocResultCard" style="display:none;">' # (voir docs/plan/PLAN.md, retour utilisateur du 20/09/2026) —
f'<div class="docAssocResultBig">✓</div>' # seul ce bouton apparaît (.is-visible posé par
f'<div class="docAssocResultSub">Toutes les paires sont associées !</div>' # forgeDocAssociationMatchResult), jamais un écran de résultat
# séparé qui remplacerait le plateau (ça reste le comportement du
# Quiz, seul mini-jeu concerné par un écran de fin distinct).
f'<div class="docMinigameRestartBar">'
f'<button type="button" class="docAssocRestartBtn">Recommencer</button>' f'<button type="button" class="docAssocRestartBtn">Recommencer</button>'
f"</div>"
f"</div></div>" f"</div></div>"
) )
@@ -250,24 +591,294 @@ def _render_association(el: dict[str, Any], _children_by_parent: dict[int | None
) )
def _render_memory_player(config: dict[str, Any]) -> str:
"""Plateau de Memory RÉELLEMENT interactif — affiché uniquement en
Mode Aperçu, même principe que _render_quiz_player/
_render_association_player. En mode "paire", chaque carte définie par
le créateur est dupliquée en deux instances partageant le même
card_index (l'appariement se fait dessus) ; en mode "single", une
seule instance par carte (simple retournement, sans appariement).
Les instances sont mélangées une seule fois ici (jamais recalculées
à chaque rendu répété d'un même Aperçu, voir la remarque dans
static/document/js/document-editor.js sur la ré-init au
rafraîchissement du canevas) puis embarquées en JSON."""
cards = config["cards"]
mode = config["mode"]
instances = []
for i, card in enumerate(cards):
instances.append({"card_index": i, "recto": card["recto"], "verso": card["verso"]})
if mode == "paire":
instances.append({"card_index": i, "recto": card["recto"], "verso": card["verso"]})
random.shuffle(instances) # NOSONAR python:S2245 - melange d'affichage, pas un usage cryptographique
config_json = html_lib.escape(json.dumps({"mode": mode, "cards": instances}), quote=True)
return (
f'<div class="docMemoryPlayer" data-memory-config="{config_json}">'
f'<div class="docMemoryCardWrap">'
f'<div class="docQuizKicker">Memory</div>'
f'<div class="docAssocTitle docMemoryTitle"></div>'
f'<div class="docAssocMeta"><span class="docMemoryProg"></span></div>'
f'<div class="docMemoryGrid"></div>'
# Même choix que l'Association ci-dessus : le plateau reste
# affiché une fois le jeu terminé, seul ce bouton apparaît.
f'<div class="docMinigameRestartBar">'
f'<button type="button" class="docMemoryRestartBtn">Recommencer</button>'
f"</div>"
f"</div></div>"
)
def _render_memory(el: dict[str, Any], _children_by_parent: dict[int | None, list[dict[str, Any]]]) -> str:
from ..labels.memory_config import sanitize_memory_config
config = sanitize_memory_config(el["attributes"])
theme_color = html_lib.escape(str(config["theme_color"]))
card_count = len(config["cards"])
card_label = "carte" if card_count <= 1 else "cartes"
mode_label = "mode paire" if config["mode"] == "paire" else "mode simple"
subtitle = f"{card_count} {card_label} · {mode_label}"
player_html = _render_memory_player(config) if config["cards"] else ""
return (
f'<div class="docMinigame" data-element-id="{el["id"]}" data-kind="memory" '
f'style="border-color:{theme_color};">'
f'<div class="docMinigameBadge">'
f'<span class="docMinigameLabel">Memory</span>'
f'<span class="docMinigamePlaceholder">{html_lib.escape(subtitle)}</span>'
f"</div>"
f"{player_html}"
f"</div>"
)
_MOTS_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
_MOTS_DIRECTIONS = (
(0, 1), # horizontale, gauche -> droite
(1, 0), # verticale, haut -> bas
(1, 1), # diagonale droite, haut-gauche -> bas-droite
(1, -1), # diagonale gauche, haut-droite -> bas-gauche
)
_MOTS_MAX_PLACEMENT_ATTEMPTS = 200
_MOTS_MAX_GRID_GROWTH_ATTEMPTS = 20
def _mots_grid_size_for_words(words: list[str]) -> int:
"""Taille de départ de la grille (carrée) — assez grande pour loger le
plus long mot ET laisser assez de cases libres pour le remplissage
aléatoire, sans grille disproportionnée pour une courte liste de mots.
_build_mots_grid grandit cette taille si le placement échoue malgré
tout (mots qui se contraignent mutuellement), donc une estimation
approximative suffit ici."""
from ..labels.mots_config import MIN_WORD_LENGTH
longest = max((len(w) for w in words), default=MIN_WORD_LENGTH)
total_letters = sum(len(w) for w in words)
return max(longest, 8, int(total_letters**0.5) + 2)
def _mots_can_place_word(
grid: list[list[str]], word: str, row: int, col: int, delta_row: int, delta_col: int, size: int
) -> bool:
for i, letter in enumerate(word):
r, c = row + i * delta_row, col + i * delta_col
if not (0 <= r < size and 0 <= c < size):
return False
if grid[r][c] not in ("", letter):
return False
return True
def _mots_place_word(grid: list[list[str]], word: str, size: int) -> list[list[int]] | None:
"""None si aucun emplacement libre n'a été trouvé après le nombre
d'essais autorisé — laisse l'appelant décider (agrandir la grille et
tout retenter, voir _build_mots_grid) plutôt que de placer le mot de
force en écrasant des lettres déjà posées."""
for _ in range(_MOTS_MAX_PLACEMENT_ATTEMPTS):
delta_row, delta_col = random.choice(_MOTS_DIRECTIONS) # nosec B311 # noqa: S311 - placement de mot, jamais crypto # NOSONAR python:S2245
row = random.randint(0, size - 1) # nosec B311 # noqa: S311 - idem # NOSONAR python:S2245
col = random.randint(0, size - 1) # nosec B311 # noqa: S311 - idem # NOSONAR python:S2245
if not _mots_can_place_word(grid, word, row, col, delta_row, delta_col, size):
continue
cells = []
for i, letter in enumerate(word):
r, c = row + i * delta_row, col + i * delta_col
grid[r][c] = letter
cells.append([r, c])
return cells
return None
def _mots_attempt_placement(words: list[str], size: int) -> dict[str, Any] | None:
grid: list[list[str]] = [["" for _ in range(size)] for _ in range(size)]
placed_words = []
# Les mots les plus longs sont placés en premier : ce sont les plus
# difficiles à caser, autant le faire tant que la grille est encore
# majoritairement libre.
for word in sorted(words, key=len, reverse=True):
cells = _mots_place_word(grid, word, size)
if cells is None:
return None
placed_words.append({"text": word, "cells": cells})
for r in range(size):
for c in range(size):
if not grid[r][c]:
grid[r][c] = random.choice(_MOTS_ALPHABET) # nosec B311 # noqa: S311 - lettre de remplissage, jamais crypto # NOSONAR python:S2245
return {"size": size, "grid": grid, "words": placed_words}
def _build_mots_grid(words: list[str]) -> dict[str, Any]:
"""Construit la grille ET la position exacte de chaque mot (jamais
recalculée côté client, voir _render_mots_player) : une grille carrée,
chaque mot placé horizontalement/verticalement/en diagonale (deux sens
de diagonale seulement, jamais à l'envers — voir _MOTS_DIRECTIONS),
les cases restantes remplies de lettres aléatoires. Si un mot ne
trouve pas sa place (mots qui se contraignent mutuellement), la
grille entière est agrandie et le placement retenté depuis zéro,
plutôt que d'abandonner silencieusement ce mot."""
if not words:
return {"size": 0, "grid": [], "words": []}
size = _mots_grid_size_for_words(words)
for _ in range(_MOTS_MAX_GRID_GROWTH_ATTEMPTS):
result = _mots_attempt_placement(words, size)
if result is not None:
return result
size += 2
# Filet de sécurité théorique : avec MAX_WORDS=10 mots de
# MAX_WORD_LENGTH=20 lettres au plus, la grille finit toujours par
# être assez grande pour tous les loger bien avant cette limite.
return _mots_attempt_placement(words, size) or {"size": size, "grid": [], "words": []}
def _render_mots_player(config: dict[str, Any]) -> str:
"""Grille de mots mêlés RÉELLEMENT interactive — affichée uniquement
en Mode Aperçu, même principe que les autres mini-jeux : la grille ET
la position exacte de chaque mot sont calculées ICI côté serveur
(_build_mots_grid, jamais recalculées côté client) puis embarquées en
JSON ; static/document/js/document-editor.js compare les coordonnées
de la sélection de l'apprenant aux coordonnées exactes de chaque mot
— jamais une simple comparaison de texte, qui se tromperait sur des
lettres partagées entre deux mots qui se croisent."""
built = _build_mots_grid(config["words"])
config_json = html_lib.escape(json.dumps(built), quote=True)
return (
f'<div class="docMotsPlayer" data-mots-config="{config_json}">'
f'<div class="docAssocCard">'
f'<div class="docQuizKicker">Mots mêlés</div>'
f'<div class="docAssocTitle">Retrouvez chaque mot caché dans la grille</div>'
f'<div class="docAssocMeta"><span class="docMotsProg"></span></div>'
f'<div class="docMotsBoard">'
f'<div class="docMotsGrid"></div>'
f'<div class="docMotsWordList"></div>'
f"</div>"
f'<div class="docMinigameRestartBar">'
f'<button type="button" class="docMotsRestartBtn">Recommencer</button>'
f"</div>"
f"</div></div>"
)
def _render_mots(el: dict[str, Any], _children_by_parent: dict[int | None, list[dict[str, Any]]]) -> str:
from ..labels.mots_config import sanitize_mots_config
config = sanitize_mots_config(el["attributes"])
theme_color = html_lib.escape(str(config["theme_color"]))
word_count = len(config["words"])
word_label = "mot" if word_count <= 1 else "mots"
player_html = _render_mots_player(config) if config["words"] else ""
return (
f'<div class="docMinigame" data-element-id="{el["id"]}" data-kind="mots" '
f'style="border-color:{theme_color};">'
f'<div class="docMinigameBadge">'
f'<span class="docMinigameLabel">Mots mêlés</span>'
f'<span class="docMinigamePlaceholder">{word_count} {word_label}</span>'
f"</div>"
f"{player_html}"
f"</div>"
)
def _render_scenario_player(config: dict[str, Any]) -> str:
"""Mise en situation RÉELLEMENT interactive — affichée uniquement en
Mode Aperçu, même principe que les autres mini-jeux : aucun
aller-retour serveur, tout le déroulé (navigation dans l'arbre de
décision, scénario suivant) est géré par static/document/js/
document-editor.js à partir du JSON embarqué (voir scenario_config.py
pour la forme exacte d'un scénario : {"title", "nodes"}, nodes[0]
étant la situation initiale). Les scénarios (plusieurs arbres
indépendants) gardent l'ORDRE d'écriture du créateur (contrairement à
l'Association/Memory/Mots mêlés, jamais mélangés) : ce sont des mises
en situation séquentielles, pas des éléments à faire correspondre ou
retrouver — un mélange n'aurait ici aucun sens pédagogique. Un seul
bloc de texte (.docScenarioSituation) sert successivement à afficher
le texte de chaque nœud visité : une fois un choix fait, le texte du
nœud suivant REMPLACE le précédent (les boutons de choix disparaissent
avec lui) — volontairement PAS le comportement du Quiz, où la
question resterait affichée à côté d'un encart de feedback séparé.
Aucune notion de bonne/mauvaise réponse ici (retour utilisateur du
20/09/2026 : "il n'y a pas de notion vrai/faux, l'utilisateur observe
les conséquences") — un nœud sans choix est simplement une fin de
branche. Réutilise les classes visuelles du Quiz
(.docQuizOptions/.docQuizNextBar/.docQuizQuestionText) plutôt que de
dupliquer ces règles, même esprit que .docAssocCard/
.docMinigameRestartBar."""
config_json = html_lib.escape(json.dumps({"scenarios": config["scenarios"]}), quote=True)
return (
f'<div class="docScenarioPlayer" data-scenario-config="{config_json}">'
f'<div class="docAssocCard">'
f'<div class="docQuizKicker">Scénario</div>'
f'<div class="docAssocMeta"><span class="docScenarioProg"></span></div>'
f'<div class="docScenarioSituation docQuizQuestionText"></div>'
f'<div class="docScenarioChoices docQuizOptions"></div>'
f'<div class="docQuizNextBar">'
f'<button type="button" class="docQuizNextBtn docScenarioNextBtn">Scénario suivant</button>'
f"</div>"
# Même choix que l'Association/Memory/Mots mêlés : le dernier
# scénario reste affiché une fois répondu, seul ce bouton
# apparaît (jamais un écran de résultat séparé — ça reste le
# comportement du Quiz).
f'<div class="docMinigameRestartBar">'
f'<button type="button" class="docScenarioRestartBtn">Recommencer</button>'
f"</div>"
f"</div></div>"
)
def _render_scenario(el: dict[str, Any], _children_by_parent: dict[int | None, list[dict[str, Any]]]) -> str:
from ..labels.scenario_config import sanitize_scenario_config
config = sanitize_scenario_config(el["attributes"])
theme_color = html_lib.escape(str(config["theme_color"]))
scenario_count = len(config["scenarios"])
scenario_label = "scénario" if scenario_count <= 1 else "scénarios"
player_html = _render_scenario_player(config) if config["scenarios"] else ""
return (
f'<div class="docMinigame" data-element-id="{el["id"]}" data-kind="scenario" '
f'style="border-color:{theme_color};">'
f'<div class="docMinigameBadge">'
f'<span class="docMinigameLabel">Scénario</span>'
f'<span class="docMinigamePlaceholder">{scenario_count} {scenario_label}</span>'
f"</div>"
f"{player_html}"
f"</div>"
)
def _render_unknown(el: dict[str, Any], _children_by_parent: dict[int | None, list[dict[str, Any]]]) -> str: def _render_unknown(el: dict[str, Any], _children_by_parent: dict[int | None, list[dict[str, Any]]]) -> str:
return f'<div class="docUnknown" data-element-id="{el["id"]}">Type inconnu : {html_lib.escape(el["kind"])}</div>' return f'<div class="docUnknown" data-element-id="{el["id"]}">Type inconnu : {html_lib.escape(el["kind"])}</div>'
_RENDERERS = { _RENDERERS = {
"row": _render_row, "row": _render_row,
"rectangle": _render_shape,
"cercle": _render_shape,
"triangle": _render_shape,
"trait": _render_shape,
"titre": _render_text, "titre": _render_text,
"paragraphe": _render_text, "paragraphe": _render_text,
"image": _render_image, "image": _render_image,
"bouton": _render_button, "bouton": _render_button,
"liste_puces": _render_list,
"liste_numerotee": _render_list,
"badge": _render_badge,
"carte": _render_carte,
"quiz": _render_quiz, "quiz": _render_quiz,
"association": _render_association, "association": _render_association,
"memory": _render_minigame_placeholder, "memory": _render_memory,
"mots": _render_minigame_placeholder, "mots": _render_mots,
"scenario": _render_minigame_placeholder, "scenario": _render_scenario,
"zones": _render_minigame_placeholder, "zones": _render_minigame_placeholder,
} }
+291 -11
View File
@@ -28,13 +28,126 @@ regroupement à chaque appel.
- **Rangée** (`row`) : conteneur flex (`gap`/`align-items`/ - **Rangée** (`row`) : conteneur flex (`gap`/`align-items`/
`justify-content` réels depuis `attributes`), enfants rendus `justify-content` réels depuis `attributes`), enfants rendus
récursivement. récursivement.
- **Formes** (`rectangle`/`cercle`/`triangle`/`trait`) : `<div>` positionné
en absolu (`x`/`y`/`width`/`height`/`rotation`/`z_index` réels) contenant
un SVG (`rect`/`circle`/`polygon`/`line` selon le type).
- **Texte** (`titre`/`paragraphe`) : `<div>` stylé selon `style` (préréglage - **Texte** (`titre`/`paragraphe`) : `<div>` stylé selon `style` (préréglage
taille/graisse/interligne) et `bold`/`italic`/`underline`/`align`/`color`. taille/graisse/interligne) et `bold`/`italic`/`underline`/`strikethrough`/
- **Image** : `<img>`, ou un bloc placeholder si `src` est vide. `align`/`color`. `underline`/`strikethrough` se combinent dans un seul
- **Bouton** : `<button>` avec son `label` et un `data-target` optionnel. `text-decoration` (`"underline line-through"` si les deux sont actifs).
`font_size`/`line_height` (vides par défaut) remplacent les valeurs du
préréglage `style` SANS toucher `font-weight` (toujours piloté par le
préréglage + `bold`). `text_transform` (`"none"` par défaut) ajoute
`text-transform` quand différent de `"none"`. `font_family`/
`letter_spacing`/`text_shadow` (vides par défaut) ajoutent leur
déclaration CSS respective quand non vides. `max_width` (optionnel, ex.
`"60ch"`, `"480px"`) ajoute `max-width` au style inline quand non vide —
pleine largeur de `.docPageContent` par défaut, retour utilisateur du
24/09/2026 (un paragraphe doit pouvoir rester plus étroit que la page,
sans dépendre d'une rangée qui en partagerait la largeur avec un frère).
Termine par `render_box_style(a)` (voir `box_style.py` ci-dessous) pour
`padding`/`margin`/`background_color`/`border_radius`/`border`/
`align_self` — attributs PARTAGÉS avec d'autres kinds, jamais dupliqués
ici (audit du 26/09/2026, réglages manquants à couvrir élément par
élément en réutilisant ce module).
- **Image** : `<img>`, ou un bloc placeholder si `src` est vide — OU, si
`attributes["svg_markup"]` est non vide (prioritaire sur `src`), un
`<div>` portant directement ce fragment SVG nettoyé par
`sanitize_svg_markup` (voir `sanitize_svg_markup.py` ci-dessous) : un
contenu vectoriel dessiné/collé par le créateur plutôt qu'un fichier
hébergé. Style inline : `object_fit` (`"cover"`/`"contain"`/`"fill"`,
toute autre valeur ignorée), `aspect_ratio` (valeur CSS libre, ex.
`"16 / 9"`), `filter_preset` (`"grayscale"`/`"sepia"`/`"blur"`, mappé
vers une vraie valeur `filter` CSS fixe — jamais une valeur de filtre
libre) + les attributs de boîte partagés (`render_box_style`, voir
`box_style.py`). `lazy_load` (`True`) ajoute `loading="lazy"` sur
l'`<img>` uniquement (comportement, pas du style). `click_behavior`
(`""`/`"link"`/`"lightbox"`) enveloppe le tout dans un `<a target="_blank"
rel="noopener noreferrer">` (si `link_url` est aussi renseigné) ou un
`<div class="docImageLightboxTrigger">` — les deux ne deviennent
réellement cliquables qu'en Mode Aperçu (voir static/document/js/
document-editor.js::forgeDocBindCanvasInteractions/
forgeDocOpenImageLightbox), même principe que les mini-jeux et la
pièce jointe d'un bouton. `caption` (non vide) enveloppe le tout dans
un `<figure><figcaption>` échappée.
- **Bouton** : `<button>` avec son `label` (enveloppé dans
`<span class="docButtonLabel">`), un `data-target` optionnel
(navigation) et un `data-attachment-filename` optionnel — marqueur
mécanique posé quand un fichier a été joint (voir
`routes/document/document_element_upload_attachment.py`), jamais
l'URL de téléchargement elle-même (ce renderer ne connaît pas le slug
du support ; `static/document/js/document-editor.js` l'assemble à
partir de `data-element-id` + `FORGE_DOCUMENT.slug`, même principe que
le reste des appels AJAX de l'éditeur). Style inline (audit du
26/09/2026, réglages manquants — colonne "Bouton") : `bold` pousse
`font-weight` à `800` (jamais en dessous du `700` déjà posé par le CSS
de base — "gras" ne fait que renforcer, jamais affaiblir, aucune
régression visuelle sur les boutons déjà créés), `italic`/
`text_transform` (`"uppercase"`/`"lowercase"`/`"capitalize"`, jamais
`"none"`)/`font_family`/`font_size`/`letter_spacing`/`text_color`
(vides par défaut) + les attributs de boîte partagés
(`render_box_style`, voir `box_style.py`). `svg_markup` (optionnel,
nettoyé par `sanitize_svg_markup`) ajoute un `<span
class="docButtonIcon">` avant OU après `.docButtonLabel` selon
`icon_position` (`"before"` par défaut), dimensionné par `icon_size`
(vide = `1em`, suit la taille du texte). États interactifs
survol/actif : effet CSS universel (`filter`/`transform`, voir
`static/document/document-editor.css`), jamais configurable par
attribut — pas de notion d'état "désactivé" pour un bouton de contenu
(ce n'est pas un vrai contrôle de formulaire).
- **Étiquette** (`badge`) : `<div>` portant `attributes["content"]`
échappé, précédé d'un `<span class="docBadgeIcon">` optionnel si
`svg_markup` est non vide (nettoyé par `sanitize_svg_markup`, même
mécanisme que le mode SVG de `"image"`). `width` (non vide) ajoute
`align-self:flex-start; width:{valeur};` en style inline — fixer une
largeur implique TOUJOURS de sortir de l'étirement pleine largeur par
défaut d'un enfant flex en colonne (voir static/document/
document-editor.css, `.docPageContent`), jamais l'un sans l'autre.
`border_radius` (non vide) ajoute `border-radius:{valeur};`. `bold`/
`uppercase` ajoutent respectivement `font-weight:800;`/
`text-transform:uppercase;` quand `True`. Tous ces attributs sont vides
ou `False` par défaut (comportement historique inchangé, aucun style
inline ajouté).
- **Carte** (`carte`) : `<div>` composé de trois blocs enfants
(`label`/`title`/`description`, tous échappés) — contenu pur, aucune
couleur/forme choisie ici (voir `element_kind_labels.md`).
- **Liste à puces/numérotée** (`liste_puces`/`liste_numerotee`) :
`<ul>` ou `<ol>` selon le `kind` (fonction privée `_render_list`,
partagée par les deux) — un `<li>` par entrée de `attributes["items"]`.
Une liste vide rend `<ul>`/`<ol>` sans enfant plutôt qu'un placeholder :
contrairement à une image sans fichier, ce n'est pas un état anormal.
Style inline (audit du 26/09/2026, portée actée avec l'utilisateur :
la LISTE ENTIÈRE uniquement, jamais par élément individuel ni de
sous-listes) : `bold`/`italic`/`underline`/`font_family`/`font_size`/
`line_height`/`text_color` (typo, `_list_text_style`) +
`list_style_type` (validé selon le `kind` — `disc`/`circle`/`square`/
`none` pour `liste_puces`, `decimal`/`decimal-leading-zero`/
`lower-roman`/`upper-roman`/`lower-alpha`/`upper-alpha`/`none` pour
`liste_numerotee`, toute autre valeur ignorée)/`list_style_position`
(`"outside"` par défaut) + les attributs de boîte partagés
(`render_box_style`, voir `box_style.py`). `marker_color`/
`marker_size`/`item_padding`/`item_spacing` (`_list_marker_style`/
`_list_item_style`) passent par des PROPRIÉTÉS PERSONNALISÉES CSS
(`--doc-marker-color`/`--doc-marker-size`/`--doc-item-padding`/
`--doc-item-spacing`) : un style inline sur le `<ul>`/`<ol>` ne peut
pas cibler directement le `::marker` ou le padding de ses `<li>`
enfants autrement — ces propriétés sont posées sur le conteneur et
consommées par `static/document/document-editor.css`
(`.docList li`/`.docList li::marker`), qui hérite jusque-là.
`svg_markup` (liste à puces UNIQUEMENT, ignoré pour `liste_numerotee`)
: puce personnalisée — nettoyé par `sanitize_svg_markup` puis encodé
en URI de données (`urllib.parse.quote`) pour `list-style-image`, qui
prime visuellement sur `list_style_type` dès qu'il est posé.
`render_content_align(a)` (voir `box_style.py`) également ajouté :
`.docList` est `display:flex; flex-direction:column;` (un `<li>` garde
son `display:list-item` propre — puce/numéro visibles — même une fois
flex-item, ce sont deux notions indépendantes en CSS).
**Bug réel corrigé** (retour utilisateur du 26/09/2026 : "si j'enlève
les puces ou que les puces se mettent à l'intérieur, il reste un
espace devant la liste, cet espace doit être supprimé") :
`padding-left:0;` est ajouté automatiquement dès que
`list_style_type="none"` ou `list_style_position="inside"` — le
`padding-left:1.4em` par défaut (`.docList`, réservé pour une puce
EXTÉRIEURE) n'a alors plus lieu d'être ; un `padding` uniforme réglé
explicitement par ailleurs (attributs de boîte partagés) reste
prioritaire (déclaré après, dans le même style inline).
- **Quiz** : toujours une carte résumant la config réelle (nombre de - **Quiz** : toujours une carte résumant la config réelle (nombre de
questions, total des points via `quiz_total_points`, minuteur si questions, total des points via `quiz_total_points`, minuteur si
activé) — sanitizée (`sanitize_quiz_config`) avant lecture, jamais un activé) — sanitizée (`sanitize_quiz_config`) avant lecture, jamais un
@@ -57,8 +170,175 @@ regroupement à chaque appel.
mélangées INDÉPENDAMMENT (`random.shuffle`, mélange d'affichage — voir mélangées INDÉPENDAMMENT (`random.shuffle`, mélange d'affichage — voir
`CODE_QUALITY.md`) puis embarquées en JSON dans un attribut `CODE_QUALITY.md`) puis embarquées en JSON dans un attribut
`data-assoc-config`, échappé pour l'HTML — même principe que le Quiz, `data-assoc-config`, échappé pour l'HTML — même principe que le Quiz,
aucun aller-retour serveur pendant qu'on joue. aucun aller-retour serveur pendant qu'on joue. Contrairement au Quiz,
- **Autres mini-jeux** (`memory`/`mots`/`scenario`/`zones`) : carte le plateau reste affiché en permanence une fois la partie terminée :
placeholder portant le libellé du type (voir seul le bouton "Recommencer" (`.docMinigameRestartBar`, partagé avec
`document_engine/labels/element_kind_labels.py`) — emplacement réservé, Memory) apparaît, jamais d'écran de résultat séparé qui le
formulaire de contenu dédié hors périmètre de cette passe. remplacerait.
- **Memory** : toujours une carte résumant la config réelle (nombre de
cartes définies, mode paire/simple) — sanitizée
(`sanitize_memory_config`) avant lecture. Si au moins une carte existe,
s'y ajoute (fonction privée `_render_memory_player`) le plateau de
retournement RÉEL et interactif : en mode `"paire"`, chaque carte
définie est DUPLIQUÉE en deux instances partageant le même
`card_index` (l'appariement se fait dessus, classique Memory) ; en mode
`"single"`, une seule instance par carte (simple retournement, sans
appariement). Les instances sont mélangées (`random.shuffle`, mélange
d'affichage — voir `CODE_QUALITY.md`) puis embarquées en JSON dans un
attribut `data-memory-config`, échappé pour l'HTML — même principe que
le Quiz/l'Association, aucun aller-retour serveur pendant qu'on joue.
Comme l'Association, le plateau reste affiché une fois toutes les
paires trouvées (ou toutes les cartes révélées en mode `"single"`) :
seul le bouton "Recommencer" (`.docMinigameRestartBar`) apparaît.
- **Mots mêlés** : toujours une carte résumant la config réelle (nombre
de mots) — sanitizée (`sanitize_mots_config`) avant lecture. Si au
moins un mot existe, s'y ajoute (fonction privée `_render_mots_player`)
la grille RÉELLE et interactive. La grille ET la position exacte de
chaque mot sont calculées ICI côté serveur (`_build_mots_grid`, jamais
recalculées côté client) : chaque mot est placé horizontalement,
verticalement, ou en diagonale (haut-gauche→bas-droite ou
haut-droite→bas-gauche — jamais à l'envers), les lettres restantes
tirées au hasard (`random.choice`/`random.randint`, tirage de jeu —
voir `CODE_QUALITY.md`) ; si un mot ne trouve pas sa place, la grille
entière est agrandie et le placement retenté depuis zéro, plutôt que
d'abandonner silencieusement ce mot. Le tout (grille + coordonnées de
chaque mot) est embarqué en JSON dans un attribut `data-mots-config`,
échappé pour l'HTML — même principe que les autres mini-jeux, aucun
aller-retour serveur pendant qu'on joue : `static/document/js/
document-editor.js` compare les coordonnées EXACTES sélectionnées par
l'apprenant à celles de chaque mot (jamais une simple comparaison de
texte, qui se tromperait sur des lettres partagées entre deux mots qui
se croisent). Comme l'Association/Memory, la grille reste affichée une
fois tous les mots trouvés : seul le bouton "Recommencer"
(`.docMinigameRestartBar`) apparaît.
- **Scénario** : toujours une carte résumant la config réelle (nombre de
scénarios) — sanitizée (`sanitize_scenario_config`) avant lecture. Si
au moins un scénario existe, s'y ajoute (fonction privée
`_render_scenario_player`) la mise en situation RÉELLE et interactive :
un scénario est un ARBRE DE DÉCISION (voir `scenario_config.py` pour la
forme exacte — `nodes[0]` = situation initiale, chaque choix pointe
vers un autre nœud via `target_id`, un nœud sans choix est une fin de
branche), pas une simple question à une seule conséquence. Aucune
notion de bonne/mauvaise réponse (retour utilisateur du 20/09/2026 :
"il n'y a pas de notion vrai/faux, l'utilisateur observe les
conséquences") : l'apprenant choisit une option, le texte du nœud visé
REMPLACE l'affichage du nœud précédent (les boutons de choix
disparaissent avec lui), et ainsi de suite jusqu'à une fin de branche
— volontairement PAS le comportement du Quiz, où la question resterait
affichée à côté d'un encart de feedback séparé. Une fois une fin de
branche atteinte, passage au scénario (arbre) suivant. Les scénarios
gardent l'ORDRE d'écriture du créateur (jamais mélangés, contrairement
à Association/Memory/Mots mêlés — ce sont des mises en situation
séquentielles, pas des éléments à faire correspondre/retrouver).
Réutilise les classes visuelles du Quiz (`.docQuizOptions`/
`.docQuizNextBar`/`.docQuizQuestionText`) plutôt que de dupliquer ces
règles. L'arbre complet (tous les nœuds/choix de tous les scénarios)
est embarqué en JSON dans un attribut `data-scenario-config`, échappé
pour l'HTML — même principe que les autres mini-jeux, aucun
aller-retour serveur pendant qu'on joue : toute la navigation dans
l'arbre se fait côté client. Comme l'Association/Memory/Mots mêlés
(mais contrairement au Quiz), le dernier scénario reste affiché une
fois une fin de branche atteinte : seul le bouton "Recommencer"
(`.docMinigameRestartBar`) apparaît. L'arbre lui-même se construit
dans une MODALE dédiée depuis le panneau Propriétés (voir
`forgeDocOpenScenarioTreeModal`, static/document/js/document-editor.js)
— trop de structure (nœuds + choix + destinations) pour la colonne
étroite du panneau Propriétés, contrairement aux autres mini-jeux. La
modale est un VRAI graphe visuel (retour utilisateur du 21/09/2026) :
chaque nœud a une position `x`/`y` (voir `scenario_config.py`),
affiché comme une carte déplaçable à la souris sur un canevas ; chaque
choix relié à un `target_id` est dessiné comme une flèche SVG
étiquetée par son texte, jamais un simple menu déroulant. Éditer le
texte/les choix d'un nœud se fait dans l'inspecteur (panneau de
droite) du nœud sélectionné ; relier un choix se fait en cliquant
"Relier" puis le nœud cible sur le graphe (mode connexion, Échap
annule).
- **Autres mini-jeux** (`zones`) : carte placeholder portant le libellé
du type (voir `document_engine/labels/element_kind_labels.py`) —
emplacement réservé, formulaire de contenu dédié hors périmètre de
cette passe.
## `box_style.py` — attributs de "boîte" partagés entre plusieurs kinds
Audit du 26/09/2026 (réglages manquants, à couvrir élément par élément) :
padding/margin/couleur de fond/arrondi/bordure par côté/position du bloc
sont des besoins IDENTIQUES pour la plupart des kinds de contenu — un
seul jeu d'attributs et une seule fonction de rendu ici, jamais réécrits
à chaque kind (voir `_render_text` pour le premier usage réel).
### `BORDER_SIDES: tuple[str, ...]`
`("top", "right", "bottom", "left")`.
### `default_border() -> dict[str, dict[str, str]]`
Un dict à 4 clés (`BORDER_SIDES`), chacune `{"style": "none", "width":
"1px", "color": "var(--doc-border)"}`.
- **Retour** : un NOUVEAU dict à chaque appel (jamais un littéral partagé
muté par référence entre deux éléments — même précaution que
`DEFAULT_QUIZ_CONFIG` côté `labels/`).
- **Exceptions** : aucune.
### `BOX_DEFAULTS: dict[str, Any]`
`{"padding": "", "margin": "", "background_color": "", "border_radius":
"", "align_self": "stretch", "width": "", "max_width": "", "height": "",
"min_height": "", "max_height": "", "min_width": "", "box_shadow": "",
"opacity": "", "content_align": "top"}` — `border` n'y figure PAS (voir
`default_border()`, à ajouter séparément par chaque appelant pour éviter
le partage par référence).
### `render_box_style(a: dict[str, Any]) -> str`
Construit les déclarations CSS inline pour chaque attribut listé dans
`BOX_DEFAULTS` (padding/margin/background_color/border_radius/width/
max_width/height/min_height/max_height/min_width/box_shadow/opacity) +
`border`/`align_self` de `a`, via une table de correspondance
(clé d'attribut, propriété CSS) plutôt qu'un bloc `if` par attribut
(complexité cognitive — voir `_render_simple_properties`/`_render_border`,
privées) — un attribut absent ou à sa valeur par défaut ne produit
AUCUNE déclaration (comportement historique inchangé). `border` est un
dict à 4 clés
(`BORDER_SIDES`), chacune `{"style", "width", "color"}` — un côté à
`style="none"` (ou absent) ne produit rien pour ce côté, jamais un
`border-top:none` explicite. `align-self` n'est ajouté que si différent
de `"stretch"` (déjà le comportement par défaut d'un enfant flex en
colonne).
- **Retour** : les déclarations CSS (`"propriete:valeur; ..."`), chaîne
vide si rien à ajouter.
- **Exceptions** : aucune.
### `render_content_align(a: dict[str, Any]) -> str`
Alignement vertical du CONTENU à l'intérieur de son propre bloc (retour
utilisateur du 26/09/2026 : "je peux augmenter la hauteur d'un
conteneur mais pas l'alignement vertical à l'intérieur") — `content_align`
(`"top"` par défaut, `"center"` ou `"bottom"`) mappé vers
`justify-content` (`""` pour `"top"`, comportement historique inchangé).
Jamais fusionné dans `render_box_style` : contrairement à `align_self`
(position du BLOC dans SON parent, valable pour tout consommateur),
l'alignement du CONTENU dépend de l'axe interne du conteneur —
`justify-content` convient à un conteneur en colonne (`_render_text`/
`_render_list`, dont les classes CSS `.docText`/`.docList` sont
`display:flex; flex-direction:column;`, et la figure d'une image
légendée, déjà flex-colonne), mais serait FAUX pour le Bouton (rangée
icône+texte : l'axe vertical y est déjà géré par `align-items`, voir
`static/document/document-editor.css`, `.docButton`) — chaque renderer
qui veut ce comportement l'appelle donc explicitement lui-même.
- **Retour** : `""` si absent/`"top"`/valeur inconnue, sinon
`"justify-content:...;"`.
- **Exceptions** : aucune.
## `sanitize_svg_markup.py` — nettoyage du contenu SVG inline d'une image
### `sanitize_svg_markup(markup: str) -> str`
Nettoie un fragment SVG selon une LISTE BLANCHE de balises/attributs
(`_ALLOWED_TAGS`/`_ALLOWED_ATTRS`, privées) — construit sur
`html.parser.HTMLParser` (tokenizer de balises pur, sans DTD ni
résolution d'entité externe) plutôt qu'un analyseur XML, qui resterait
exposé aux attaques classiques d'entité externe sur une entrée non
fiable. Toute balise absente de la liste blanche (`<script>`,
`<foreignObject>`, `<a>`, `<use>`, `<image>`...) disparaît AVEC son
contenu ; tout attribut absent (`on*`, `style`, `href`/`xlink:href`,
`class`...) disparaît seul, la balise porteuse étant conservée si elle
est autorisée. Appelé à CHAQUE rendu (`_render_image`), jamais
seulement à l'écriture — même défense en profondeur que
`html.escape` sur les autres kinds.
- **Retour** : le fragment SVG nettoyé, sûr à insérer tel quel dans le
HTML rendu.
- **Exceptions** : aucune.
@@ -0,0 +1,129 @@
"""Nettoyage d'un fragment SVG saisi/collé par le créateur comme contenu
d'une image (voir render_document_element._render_image) — jamais un
rendu direct de `attributes["svg_markup"]`, qui exposerait une injection
XSS triviale (`<script>`, `onload="..."`, `href="javascript:..."`).
Construit sur `html.parser.HTMLParser` (analyseur de balises pur, sans
DTD ni résolution d'entité externe) plutôt que sur un analyseur XML
(`xml.etree.ElementTree`), qui resterait exposé aux attaques classiques
d'entité externe/"milliard de rires" sur une entrée non fiable."""
from html.parser import HTMLParser
_ALLOWED_TAGS = {
"svg",
"path",
"circle",
"rect",
"line",
"polyline",
"polygon",
"ellipse",
"g",
"defs",
"lineargradient",
"radialgradient",
"stop",
"title",
"desc",
}
_ALLOWED_ATTRS = {
"viewbox",
"width",
"height",
"fill",
"stroke",
"stroke-width",
"stroke-linecap",
"stroke-linejoin",
"stroke-dasharray",
"opacity",
"fill-opacity",
"stroke-opacity",
"fill-rule",
"d",
"cx",
"cy",
"r",
"rx",
"ry",
"x",
"y",
"x1",
"y1",
"x2",
"y2",
"points",
"transform",
"offset",
"stop-color",
"stop-opacity",
}
class _SvgSanitizer(HTMLParser):
"""Reconstruit un fragment SVG balise par balise, en ne conservant que
les éléments/attributs de la liste blanche — jamais de liste noire
(une balise/un attribut absent de la liste blanche est TOUJOURS
supprimé, y compris un futur ajout du format SVG qu'on n'aurait pas
anticipé ici)."""
def __init__(self) -> None:
super().__init__(convert_charrefs=True)
self.output: list[str] = []
self._skip_depth = 0
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
self._open_tag(tag, attrs, self_closing=False)
def handle_startendtag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
self._open_tag(tag, attrs, self_closing=True)
def _open_tag(self, tag: str, attrs: list[tuple[str, str | None]], *, self_closing: bool) -> None:
tag = tag.lower()
if self._skip_depth or tag not in _ALLOWED_TAGS:
# Une balise auto-fermante (ex. <script/>) n'aura jamais de
# handle_endtag correspondant : incrémenter ici ferait fuir
# tout le reste du document dans un skip permanent.
if not self_closing:
self._skip_depth += 1
return
kept = [(name.lower(), value) for name, value in attrs if name.lower() in _ALLOWED_ATTRS]
attrs_html = "".join(f' {name}="{_escape_attr(value or "")}"' for name, value in kept)
self.output.append(f"<{tag}{attrs_html}{'/>' if self_closing else '>'}")
def handle_endtag(self, tag: str) -> None:
if self._skip_depth:
self._skip_depth -= 1
return
if tag.lower() in _ALLOWED_TAGS:
self.output.append(f"</{tag.lower()}>")
def handle_data(self, data: str) -> None:
if not self._skip_depth:
self.output.append(_escape_text(data))
def _escape_attr(value: str) -> str:
return value.replace("&", "&amp;").replace('"', "&quot;").replace("<", "&lt;").replace(">", "&gt;")
def _escape_text(value: str) -> str:
return value.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
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)
+10
View File
@@ -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
+32
View File
@@ -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)
+57
View File
@@ -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.
+13
View File
@@ -8,8 +8,21 @@ from . import ( # noqa: F401 - enregistre les routes definies dans chaque modul
document_edit, document_edit,
document_element_add, document_element_add,
document_element_delete, document_element_delete,
document_element_download_attachment,
document_element_move, document_element_move,
document_element_move_to_page,
document_element_update, document_element_update,
document_element_upload_attachment,
document_element_upload_image,
document_new, document_new,
document_page_add,
document_page_delete,
document_page_delete_all,
document_page_move,
document_page_rename,
document_page_vertical_align,
document_render, document_render,
document_theme_apply,
document_theme_preview,
document_uploaded_file,
) )
+61 -4
View File
@@ -1,3 +1,5 @@
from typing import Any
from flask import render_template from flask import render_template
import db import db
@@ -11,14 +13,69 @@ def document_edit(slug: str) -> str:
support = un projet = un document, voir docs/plan/PLAN.md). Contraste support = un projet = un document, voir docs/plan/PLAN.md). Contraste
avec l'environnement 2D (clic sur une carte -> game_dashboard qui liste avec l'environnement 2D (clic sur une carte -> game_dashboard qui liste
ses écrans -> éditeur de scène) : ici la carte "Mes formations" mène ses écrans -> éditeur de scène) : ici la carte "Mes formations" mène
directement ici.""" directement ici.
Un support est composé de plusieurs PAGES (voir document_engine/
pages/, retour utilisateur du 21/09/2026 : "il faut implémenter un
système de page") : le canevas n'affiche au chargement que la
PREMIÈRE page (triée par order_index) — changer de page se fait
ensuite entièrement côté client via /document/<slug>/render?page_id=
(voir static/document/js/document-editor.js), jamais un rechargement
complet de cette route. La navigation entre pages (ajout/renommage/
suppression/réordonnancement) vit dans une section dédiée du panneau
gauche (retour utilisateur du 21/09/2026 : "une section qui s'ajoute
dans le panneau de gauche pour ajouter une page et naviguer entre
elles"), qui n'a besoin que des métadonnées de page (`pages`), jamais
d'un rendu de leur contenu.
Les attributs de chaque élément sont revalidés (document_engine.
sanitize_element_attributes) avant d'atteindre le client — jamais les
valeurs brutes stockées telles quelles : un élément dont le schéma a
évolué depuis sa création (ex. le mini-jeu Scénario, passé d'une liste
plate à un arbre de décision) ferait sinon planter silencieusement le
panneau Propriétés côté client, qui suppose la forme ACTUELLE (bug réel
constaté le 21/09/2026)."""
support = db.support_meta(slug) support = db.support_meta(slug)
elements = document_engine.list_document_elements(slug) pages = document_engine.list_document_pages(slug)
# active_page peut être None : un support peut avoir 0 page (retour
# utilisateur du 26/09/2026, voir document_engine/pages/pages.md) — le
# canevas et le panneau Pages doivent alors afficher un état "aucune
# page" plutôt que de planter, voir document_edit.html et
# document-editor.js (forgeDocSwitchPage/forgeDocRefreshCanvas).
active_page = pages[0] if pages else None
active_elements: list[dict[str, Any]] = (
[
{**el, "attributes": document_engine.sanitize_element_attributes(el["kind"], el["attributes"])}
for el in document_engine.list_document_elements(slug, active_page["id"])
]
if active_page is not None
else []
)
active_theme = document_engine.get_document_theme_entry(support["theme"]) if support["theme"] else None
return render_template( return render_template(
"document/document_edit.html", "document/document_edit.html",
support=support, support=support,
elements=elements, pages=pages,
rendered_document=document_engine.render_document(elements), active_page=active_page,
elements=active_elements,
rendered_document=document_engine.render_document(active_elements),
element_library=document_engine.ELEMENT_LIBRARY, element_library=document_engine.ELEMENT_LIBRARY,
element_kind_labels=document_engine.ELEMENT_KIND_LABELS, element_kind_labels=document_engine.ELEMENT_KIND_LABELS,
active_theme=active_theme,
# Seuls les champs utiles à la modale "Utiliser un modèle" côté
# client (voir static/document/js/document-editor.js) — jamais le
# `seed_pages` complet, inutilement volumineux et non nécessaire
# côté client (l'aperçu et l'application se font tous deux en
# appelant le serveur, voir document_theme_preview.py/
# document_theme_apply.py). Pas de route JSON dédiée pour une
# donnée 100% statique côté serveur : le catalogue tient déjà
# dans le contexte de cette page (voir document_engine/themes/
# theme_catalog.py).
document_themes=[
{"id": t["id"], "name": t["name"], "category": t["category"], "description": t["description"]}
for t in document_engine.DOCUMENT_THEMES
],
) )
+30 -10
View File
@@ -10,25 +10,45 @@ from core.flask_app import app
@app.route("/document/<slug>/elements/add", methods=["POST"]) @app.route("/document/<slug>/elements/add", methods=["POST"])
def document_element_add(slug: str) -> Response | tuple[Response, int]: def document_element_add(slug: str) -> Response | tuple[Response, int]:
"""Ajoute un élément — appelée en AJAX depuis la bibliothèque du """Ajoute un élément à une page précise — appelée en AJAX depuis la
panneau gauche (clic ou glisser-déposer initial), OU par le moteur de bibliothèque du panneau gauche (clic ou glisser-déposer initial), OU
layout lui-même pour créer une rangée à la volée au moment d'un dépôt par le moteur de layout lui-même pour créer une rangée à la volée au
latéral (kind="row" — jamais choisi directement dans la bibliothèque, moment d'un dépôt latéral (kind="row" — jamais choisi directement
absent de document_engine.ELEMENT_LIBRARY, mais un kind valide comme dans la bibliothèque, absent de document_engine.ELEMENT_LIBRARY, mais
un autre pour cette route ; voir static/document/js/document-editor.js). un kind valide comme un autre pour cette route ; voir
parent_id, quand fourni, place l'élément directement dans une rangée static/document/js/document-editor.js). parent_id, quand fourni,
existante.""" place l'élément directement dans une rangée existante DE CETTE MÊME
PAGE."""
kind = request.form.get("kind", "") kind = request.form.get("kind", "")
if kind not in document_engine.ELEMENT_KIND_LABELS: if kind not in document_engine.ELEMENT_KIND_LABELS:
return jsonify({"error": "type d'élément inconnu"}), 400 return jsonify({"error": "type d'élément inconnu"}), 400
page_id = request.form.get("page_id", type=int)
if page_id is None or document_engine.get_document_page(slug, page_id) is None:
return jsonify({"error": "page introuvable"}), 404
parent_id = request.form.get("parent_id", type=int) parent_id = request.form.get("parent_id", type=int)
element_id = document_engine.add_document_element(slug, kind, parent_id=parent_id)
# Un mini-jeu occupe toute la page, à lui seul (retour utilisateur du
# 23/09/2026 : "un mini jeu dois occupper toute une page" -> "une page
# avec mini-jeu = uniquement ce mini-jeu"). Vérifié ici, POINT D'ENTRÉE
# UNIQUE de tout ajout d'élément (bibliothèque, glisser-déposer,
# création de rangée à la volée, Annuler/Rétablir) : jamais dupliqué
# côté client, qui se contente d'afficher l'erreur renvoyée.
is_minigame = kind in document_engine.MINIGAME_KINDS
existing = document_engine.list_document_elements(slug, page_id)
if is_minigame and parent_id is not None:
return jsonify({"error": "Un mini-jeu ne peut pas être placé dans une rangée."}), 400
if is_minigame and existing:
return jsonify({"error": "Un mini-jeu doit être seul sur sa page — ajoutez-le sur une nouvelle page."}), 400
if not is_minigame and any(el["kind"] in document_engine.MINIGAME_KINDS for el in existing):
return jsonify({"error": "Cette page contient déjà un mini-jeu qui occupe toute la page."}), 400
element_id = document_engine.add_document_element(slug, kind, page_id=page_id, parent_id=parent_id)
element = db.assert_not_none( element = db.assert_not_none(
document_engine.get_document_element(slug, element_id), document_engine.get_document_element(slug, element_id),
"element_id vient d'etre cree par add_document_element juste au-dessus", "element_id vient d'etre cree par add_document_element juste au-dessus",
) )
elements_by_parent: dict[int | None, list[dict[str, Any]]] = {} elements_by_parent: dict[int | None, list[dict[str, Any]]] = {}
for el in document_engine.list_document_elements(slug): for el in document_engine.list_document_elements(slug, page_id):
elements_by_parent.setdefault(el["parent_id"], []).append(el) elements_by_parent.setdefault(el["parent_id"], []).append(el)
return jsonify( return jsonify(
{ {
@@ -0,0 +1,30 @@
import os
from flask import send_from_directory
from werkzeug.exceptions import NotFound
from werkzeug.wrappers import Response
import db
import document_engine
from core.flask_app import app
@app.route("/document/<slug>/elements/<int:element_id>/download-attachment")
def document_element_download_attachment(slug: str, element_id: int) -> Response:
"""Sert le fichier joint à un bouton (voir
document_element_upload_attachment.py) sous son nom d'origine
(`download_name`), jamais sous son nom de stockage anonymisé
(`attachment_stored_name`, un UUID)."""
element = document_engine.get_document_element(slug, element_id)
if element is None:
raise NotFound
stored_name = str(element["attributes"].get("attachment_stored_name", ""))
original_filename = str(element["attributes"].get("attachment_filename", ""))
if not stored_name:
raise NotFound
return send_from_directory(
os.path.join(db.support_dir(slug), "attachments"),
stored_name,
as_attachment=True,
download_name=original_filename or stored_name,
)
@@ -0,0 +1,24 @@
from flask import jsonify, request
from werkzeug.wrappers import Response
import document_engine
from core.flask_app import app
@app.route("/document/<slug>/elements/<int:element_id>/move-to-page", methods=["POST"])
def document_element_move_to_page(slug: str, element_id: int) -> Response | tuple[Response, int]:
"""Déplace un élément vers une AUTRE page du support — appelée par la
pagination automatique côté client (retour utilisateur du 23/09/2026 :
"si il n'y a plus de place sur la page il faut automatiquement créer
une autre page [et y] coller le contenu", voir
static/document/js/document-editor.js, forgeDocCheckPageOverflow),
jamais par un glisser-déposer manuel (qui reste sur
document_element_move, réordonnancement dans la MÊME page)."""
payload = request.get_json(force=True) or {}
target_page_id = payload.get("target_page_id")
if not isinstance(target_page_id, int) or isinstance(target_page_id, bool):
return jsonify({"error": "page cible introuvable"}), 404
if document_engine.get_document_page(slug, target_page_id) is None:
return jsonify({"error": "page cible introuvable"}), 404
document_engine.move_document_element_to_page(slug, element_id, target_page_id)
return jsonify({"ok": True})
+14 -12
View File
@@ -14,28 +14,30 @@ def document_element_update(slug: str, element_id: int) -> Response | tuple[Resp
Propriétés (voir static/document/js/document-editor.js) envoie Propriétés (voir static/document/js/document-editor.js) envoie
systématiquement l'état complet de ses champs. systématiquement l'état complet de ses champs.
Quiz et Association sont les seuls kinds revalidés côté serveur Quiz, Association, Memory, Mots mêlés et Scénario sont les seuls
(sanitize_quiz_config/sanitize_association_config, même convention que kinds revalidés côté serveur (document_engine.sanitize_element_attributes,
game_engine/rendering/quiz_box_config.py côté jeu) : leur forme (liste même convention que game_engine/rendering/quiz_box_config.py côté
de questions/choix, liste de paires) doit rester structurellement jeu) : leur forme (liste de questions/choix, liste de paires, liste
correcte pour que le rendu ne plante jamais, contrairement aux autres de cartes, liste de mots, arbre de scénarios) doit rester
kinds (texte/forme/image...) dont les attributs sont de simples structurellement correcte pour que le rendu ne plante jamais,
valeurs scalaires sans structure à garantir.""" contrairement aux autres kinds (texte/forme/image...) dont les
attributs sont de simples valeurs scalaires sans structure à
garantir. Même sanitizer utilisé en LECTURE par document_edit.py/
document_render.py — un élément dont les attributs stockés datent
d'un schéma devenu obsolète est ainsi normalisé partout, jamais
seulement à l'écriture."""
element = document_engine.get_document_element(slug, element_id) element = document_engine.get_document_element(slug, element_id)
if element is None: if element is None:
return jsonify({"error": "élément introuvable"}), 404 return jsonify({"error": "élément introuvable"}), 404
attributes: dict[str, Any] = request.get_json(force=True) or {} attributes: dict[str, Any] = request.get_json(force=True) or {}
if element["kind"] == "quiz": attributes = document_engine.sanitize_element_attributes(element["kind"], attributes)
attributes = document_engine.sanitize_quiz_config(attributes)
elif element["kind"] == "association":
attributes = document_engine.sanitize_association_config(attributes)
document_engine.update_document_element_attributes(slug, element_id, attributes) document_engine.update_document_element_attributes(slug, element_id, attributes)
element = db.assert_not_none( element = db.assert_not_none(
document_engine.get_document_element(slug, element_id), document_engine.get_document_element(slug, element_id),
"element_id verifie present juste au-dessus, aucune suppression concurrente possible entre-temps ici", "element_id verifie present juste au-dessus, aucune suppression concurrente possible entre-temps ici",
) )
elements_by_parent: dict[int | None, list[dict[str, Any]]] = {} elements_by_parent: dict[int | None, list[dict[str, Any]]] = {}
for el in document_engine.list_document_elements(slug): for el in document_engine.list_document_elements(slug, element["page_id"]):
elements_by_parent.setdefault(el["parent_id"], []).append(el) elements_by_parent.setdefault(el["parent_id"], []).append(el)
return jsonify( return jsonify(
{ {
@@ -0,0 +1,54 @@
import os
import uuid
from typing import Any
from flask import jsonify, request
from werkzeug.wrappers import Response
import db
import document_engine
from core.flask_app import app
@app.route("/document/<slug>/elements/<int:element_id>/upload-attachment", methods=["POST"])
def document_element_upload_attachment(slug: str, element_id: int) -> Response | tuple[Response, int]:
"""Joint un fichier téléchargeable à un bouton (voir _render_button,
document_engine/rendering/render_document_element.py) — mirroir de
routes/uploads/upload_file.py côté jeu, mais stocké sous le dossier du
SUPPORT (db.support_dir) et réservé au kind "bouton" (un fichier joint
n'a de sens que pour déclencher un téléchargement au clic)."""
element = document_engine.get_document_element(slug, element_id)
if element is None:
return jsonify({"error": "élément introuvable"}), 404
if element["kind"] != "bouton":
return jsonify({"error": "seul un bouton peut recevoir un fichier joint"}), 400
f = request.files.get("file")
if not f or not f.filename:
return jsonify({"error": "Aucun fichier reçu"}), 400
original_filename = f.filename
ext = "".join(c for c in os.path.splitext(original_filename)[1].lower() if c.isalnum() or c == ".")[:10]
stored_name = uuid.uuid4().hex + ext
attachments_dir = os.path.join(db.support_dir(slug), "attachments")
os.makedirs(attachments_dir, exist_ok=True)
f.save(os.path.join(attachments_dir, stored_name))
attributes = {
**element["attributes"],
"attachment_stored_name": stored_name,
"attachment_filename": original_filename,
}
document_engine.update_document_element_attributes(slug, element_id, attributes)
element = db.assert_not_none(
document_engine.get_document_element(slug, element_id),
"element_id verifie present juste au-dessus, aucune suppression concurrente possible entre-temps ici",
)
elements_by_parent: dict[int | None, list[dict[str, Any]]] = {}
for el in document_engine.list_document_elements(slug, element["page_id"]):
elements_by_parent.setdefault(el["parent_id"], []).append(el)
return jsonify(
{
"ok": True,
"attributes": element["attributes"],
"rendered_html": document_engine.render_document_element(element, elements_by_parent),
}
)
@@ -0,0 +1,61 @@
import os
import uuid
from typing import Any
from flask import jsonify, request, url_for
from werkzeug.wrappers import Response
import db
import document_engine
from core.flask_app import app
_ALLOWED_IMAGE_EXTENSIONS = (".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg")
@app.route("/document/<slug>/elements/<int:element_id>/upload-image", methods=["POST"])
def document_element_upload_image(slug: str, element_id: int) -> Response | tuple[Response, int]:
"""Téléverse un fichier image pour un élément "image" (voir
_render_image, document_engine/rendering/render_document_element.py)
— mirroir de document_element_upload_attachment.py, mais stocké sous
`uploads/` (jamais `attachments/`, réservé au bouton) et réservé au
kind "image". `svg_markup` est vidé au passage : un fichier
téléversé implique `src`, jamais les deux modes en même temps (voir
element_default_attributes, `svg_markup` prioritaire sur `src` au
rendu — le vider ici évite qu'un ancien code SVG masque
silencieusement la photo qui vient d'être choisie)."""
element = document_engine.get_document_element(slug, element_id)
if element is None:
return jsonify({"error": "élément introuvable"}), 404
if element["kind"] != "image":
return jsonify({"error": "seul un élément image peut recevoir un fichier téléversé"}), 400
f = request.files.get("file")
if not f or not f.filename:
return jsonify({"error": "Aucun fichier reçu"}), 400
ext = os.path.splitext(f.filename)[1].lower()
if ext not in _ALLOWED_IMAGE_EXTENSIONS:
return jsonify({"error": "Format d'image non pris en charge (PNG, JPG, GIF, WEBP, SVG uniquement)"}), 400
stored_name = uuid.uuid4().hex + ext
uploads_dir = os.path.join(db.support_dir(slug), "uploads")
os.makedirs(uploads_dir, exist_ok=True)
f.save(os.path.join(uploads_dir, stored_name))
attributes = {
**element["attributes"],
"src": url_for("document_uploaded_file", slug=slug, filename=stored_name),
"svg_markup": "",
}
document_engine.update_document_element_attributes(slug, element_id, attributes)
element = db.assert_not_none(
document_engine.get_document_element(slug, element_id),
"element_id verifie present juste au-dessus, aucune suppression concurrente possible entre-temps ici",
)
elements_by_parent: dict[int | None, list[dict[str, Any]]] = {}
for el in document_engine.list_document_elements(slug, element["page_id"]):
elements_by_parent.setdefault(el["parent_id"], []).append(el)
return jsonify(
{
"ok": True,
"attributes": element["attributes"],
"rendered_html": document_engine.render_document_element(element, elements_by_parent),
}
)
+19
View File
@@ -0,0 +1,19 @@
from flask import jsonify, request
from werkzeug.wrappers import Response
import document_engine
from core.flask_app import app
@app.route("/document/<slug>/pages/add", methods=["POST"])
def document_page_add(slug: str) -> Response:
"""Ajoute une page en fin de la bande d'onglets — appelée en AJAX
depuis le "+" de static/document/js/document-editor.js. Le client
bascule ensuite lui-même la page active sur cette nouvelle page (via
/document/<slug>/render?page_id=<id>), jamais un rendu HTML renvoyé
ici : une page neuve n'a aucun élément à rendre."""
payload = request.get_json(silent=True) or {}
title = payload.get("title")
page_id = document_engine.add_document_page(slug, title=title)
page = document_engine.get_document_page(slug, page_id)
return jsonify({"id": page_id, "title": page["title"] if page else title})
+18
View File
@@ -0,0 +1,18 @@
from flask import jsonify
from werkzeug.wrappers import Response
import document_engine
from core.flask_app import app
@app.route("/document/<slug>/pages/<int:page_id>/delete", methods=["POST"])
def document_page_delete(slug: str, page_id: int) -> Response | tuple[Response, int]:
"""Supprime une page, y compris la dernière restante — un support à 0
page est un état valide (retour utilisateur du 26/09/2026 : "l'éditeur
ne dois plus etre obliger d'avoir une page active", voir
document_engine/pages/pages.md). Voir aussi document_page_delete_all.py
pour tout supprimer d'un coup."""
if document_engine.get_document_page(slug, page_id) is None:
return jsonify({"error": "page introuvable"}), 404
document_engine.delete_document_page(slug, page_id)
return jsonify({"ok": True})
@@ -0,0 +1,17 @@
from flask import jsonify
from werkzeug.wrappers import Response
import document_engine
from core.flask_app import app
@app.route("/document/<slug>/pages/delete-all", methods=["POST"])
def document_page_delete_all(slug: str) -> Response:
"""Supprime toutes les pages du support d'un coup (retour utilisateur :
"une option dans page pour supprimer toute les page d'un coup") — le
support se retrouve à 0 page, exactement comme un support neuf. Action
destructive et irréversible, jamais appelée sans confirmation
explicite côté client (voir static/document/js/document-editor.js,
forgeDocDeleteAllPages)."""
document_engine.delete_all_document_pages(slug)
return jsonify({"ok": True})
+18
View File
@@ -0,0 +1,18 @@
from flask import jsonify, request
from werkzeug.wrappers import Response
import document_engine
from core.flask_app import app
@app.route("/document/<slug>/pages/<int:page_id>/move", methods=["POST"])
def document_page_move(slug: str, page_id: int) -> Response | tuple[Response, int]:
"""Réordonne une page dans la bande d'onglets — new_index calculé
côté client (glisser-déposer d'un onglet, voir static/document/js/
document-editor.js)."""
if document_engine.get_document_page(slug, page_id) is None:
return jsonify({"error": "page introuvable"}), 404
payload = request.get_json(force=True) or {}
new_index = int(payload.get("new_index", 0))
document_engine.move_document_page(slug, page_id, new_index)
return jsonify({"ok": True})
+20
View File
@@ -0,0 +1,20 @@
from flask import jsonify, request
from werkzeug.wrappers import Response
import document_engine
from core.flask_app import app
@app.route("/document/<slug>/pages/<int:page_id>/rename", methods=["POST"])
def document_page_rename(slug: str, page_id: int) -> Response | tuple[Response, int]:
"""Renomme une page — renvoie le titre RÉELLEMENT persisté (jamais
celui envoyé tel quel) : update_document_page ignore silencieusement
un titre vide, le client doit refléter ce que le serveur a vraiment
gardé, même convention que document_element_update.py."""
if document_engine.get_document_page(slug, page_id) is None:
return jsonify({"error": "page introuvable"}), 404
payload = request.get_json(force=True) or {}
document_engine.update_document_page(slug, page_id, str(payload.get("title", "")))
page = document_engine.get_document_page(slug, page_id)
title = page["title"] if page else ""
return jsonify({"ok": True, "title": title})
@@ -0,0 +1,22 @@
from flask import jsonify, request
from werkzeug.wrappers import Response
import document_engine
from core.flask_app import app
@app.route("/document/<slug>/pages/<int:page_id>/vertical-align", methods=["POST"])
def document_page_vertical_align(slug: str, page_id: int) -> Response | tuple[Response, int]:
"""Règle l'alignement vertical du contenu d'une page (voir panneau
Propriétés affiché quand l'onglet "Pages" est actif, static/document/
js/document-editor.js::forgeDocRenderPageProps) — renvoie la valeur
RÉELLEMENT persistée (jamais celle envoyée telle quelle) : une valeur
invalide retombe silencieusement sur "top", même convention que
document_page_rename.py pour un titre vide."""
if document_engine.get_document_page(slug, page_id) is None:
return jsonify({"error": "page introuvable"}), 404
payload = request.get_json(force=True) or {}
document_engine.set_document_page_vertical_align(slug, page_id, str(payload.get("vertical_align", "")))
page = document_engine.get_document_page(slug, page_id)
vertical_align = page["vertical_align"] if page else "top"
return jsonify({"ok": True, "vertical_align": vertical_align})
+31 -9
View File
@@ -1,4 +1,4 @@
from flask import jsonify from flask import jsonify, request
from werkzeug.wrappers import Response from werkzeug.wrappers import Response
import document_engine import document_engine
@@ -6,11 +6,33 @@ from core.flask_app import app
@app.route("/document/<slug>/render") @app.route("/document/<slug>/render")
def document_render(slug: str) -> Response: def document_render(slug: str) -> Response | tuple[Response, int]:
"""Rendu HTML complet et à jour du document — appelé par le moteur de """Rendu HTML complet et à jour d'UNE PAGE du document (?page_id=) —
layout côté client après toute mutation structurelle (ajout/déplacement/ appelé par le moteur de layout côté client après toute mutation
suppression) pour reconstruire le canevas depuis la vérité serveur structurelle (ajout/déplacement/suppression) pour reconstruire le
(recalcul réel des rangées, jamais une simple retouche DOM locale, voir canevas depuis la vérité serveur (recalcul réel des rangées, jamais
docs/plan/PLAN.md — "recalcul au déplacement").""" une simple retouche DOM locale, voir docs/plan/PLAN.md — "recalcul au
elements = document_engine.list_document_elements(slug) déplacement"). Le canevas n'affiche jamais qu'une seule page à la
return jsonify({"html": document_engine.render_document(elements), "elements": elements}) fois (voir document_engine/pages/), d'où ce paramètre.
`elements` est revalidé (document_engine.sanitize_element_attributes)
avant d'être renvoyé au client — même raison que routes/document/
document_edit.py : le panneau Propriétés reçoit `data.elements`
directement depuis cette route à chaque rafraîchissement du canevas."""
page_id = request.args.get("page_id", type=int)
if page_id is None:
return jsonify({"error": "page introuvable"}), 404
page = document_engine.get_document_page(slug, page_id)
if page is None:
return jsonify({"error": "page introuvable"}), 404
elements = [
{**el, "attributes": document_engine.sanitize_element_attributes(el["kind"], el["attributes"])}
for el in document_engine.list_document_elements(slug, page_id)
]
return jsonify(
{
"html": document_engine.render_document(elements),
"elements": elements,
"vertical_align": page["vertical_align"],
}
)
+44
View File
@@ -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})
+33
View File
@@ -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)
+19
View File
@@ -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);
}
+158 -21
View File
@@ -5,6 +5,13 @@
{% block extra_head %} {% block extra_head %}
<link rel="stylesheet" <link rel="stylesheet"
href="{{ url_for('static', filename='document/document-editor.css') }}"> href="{{ url_for('static', filename='document/document-editor.css') }}">
{% if active_theme %}
{% if active_theme.font_url %}
<link rel="stylesheet" href="{{ active_theme.font_url }}">
{% endif %}
<link rel="stylesheet"
href="{{ url_for('static', filename=active_theme.css_path) }}">
{% endif %}
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<div class="docEditor3" id="docEditor3" data-slug="{{ support.slug }}"> <div class="docEditor3" id="docEditor3" data-slug="{{ support.slug }}">
@@ -76,6 +83,23 @@
<circle cx="12" cy="12" r="3" /> <circle cx="12" cy="12" r="3" />
</svg><span class="docBtnLabel">Aperçu</span> </svg><span class="docBtnLabel">Aperçu</span>
</button> </button>
<button type="button"
class="docBtnSecondary"
id="docTemplateBtn">
<svg width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round">
<rect x="3" y="3" width="7" height="7" rx="1" />
<rect x="14" y="3" width="7" height="7" rx="1" />
<rect x="3" y="14" width="7" height="7" rx="1" />
<rect x="14" y="14" width="7" height="7" rx="1" />
</svg><span class="docBtnLabel">Utiliser un modèle</span>
</button>
<div class="docDividerV"></div> <div class="docDividerV"></div>
<div class="docWidthPicker" <div class="docWidthPicker"
id="docWidthPicker" id="docWidthPicker"
@@ -95,21 +119,79 @@
<div class="docBodyWrap"> <div class="docBodyWrap">
<div class="docSidebarClose" id="docCloseLeft">← Retour au document</div> <div class="docSidebarClose" id="docCloseLeft">← Retour au document</div>
<div class="docSidebar docSidebarLeft" id="docSidebarLeft"> <div class="docSidebar docSidebarLeft" id="docSidebarLeft">
{% for category_key, category in element_library.items() %} {# Panneau gauche à onglets (retour utilisateur du 23/09/2026 :
<div> "il dois avoir deux onglets") : "Mise en page" (bibliothèque
<div class="docSectionLabel">{{ category.label }}</div> d'éléments, contenu inchangé) actif par défaut — le panneau
{% if category_key == "mise_en_page" %} "Pages" (gestion complète des pages : liste/réordonner/ajouter/
<div class="docShapesGrid"> supprimer/renommer) est un espace dédié plein-écran plutôt que
{% for kind in category.kinds %} de se partager la hauteur avec la bibliothèque comme les
<div class="docLibItem docLibItem--shape" itérations précédentes (bande de vignettes, puis carrousel). #}
draggable="true" <div class="docSidebarTabs" role="tablist">
data-add="{{ kind }}"> <button type="button"
<span class="docLibIcon docLibIcon--{{ kind }}"></span> class="docSidebarTab"
<span>{{ element_kind_labels[kind] }}</span> id="docTabPages"
</div> role="tab"
{% endfor %} aria-selected="false"
aria-controls="docTabPanelPages"
data-tab="pages">Pages</button>
<button type="button"
class="docSidebarTab is-active"
id="docTabLayout"
role="tab"
aria-selected="true"
aria-controls="docTabPanelLayout"
data-tab="layout">Mise en page</button>
</div>
<div class="docSidebarTabPanel"
id="docTabPanelPages"
role="tabpanel"
aria-labelledby="docTabPages"
hidden>
{# Bouton d'ajout EN HAUT, toujours visible (retour utilisateur du
23/09/2026 : "le bouton pour ajouter une page dois être en haut
toujours visible" — avec beaucoup de pages, il disparaissait en
bas de la liste défilante). #}
<button type="button" class="docBtnSecondary docPageManagerAdd" id="docPageManagerAdd">+ Ajouter une page</button>
<button type="button"
class="docDeleteBtn docPageManagerDeleteAll"
id="docPageManagerDeleteAll"
{{ 'disabled' if not pages else '' }}>🗑 Supprimer toutes les pages</button>
<div class="docPageManagerList" id="docPageManagerList">
{% for page in pages %}
<div class="docPageRow {{ 'is-active' if page.id == active_page.id else '' }}"
role="button"
tabindex="0"
draggable="true"
data-page-id="{{ page.id }}">
{# //NOSONAR Web:S6819,Web:MouseEventWithoutKeyboardEquivalentCheck - div+role=button
volontaire : la rangée contient de vrais <button> d'action (renommer/supprimer),
qu'un <button> englobant ne pourrait pas contenir validement. L'équivalent clavier
(Entrée/Espace) est posé côté JS (forgeDocRenderPageManagerList), donc le finding
clavier de Sonar est un faux positif : l'analyseur statique ne voit pas les
addEventListener dynamiques. #}
<span class="docPageRowHandle" aria-hidden="true">⠿</span>
<span class="docPageRowNumber">{{ loop.index }}</span>
<span class="docPageRowTitle"><span class="docPageRowTitleText">{{ page.title }}</span></span>
<span class="docPageRowActions">
<button type="button"
class="docPageRowRename"
data-page-id="{{ page.id }}"
aria-label="Renommer cette page"
title="Renommer cette page">✎</button>
<button type="button"
class="docPageRowDelete"
data-page-id="{{ page.id }}"
aria-label="Supprimer cette page"
title="Supprimer cette page">✕</button>
</span>
</div> </div>
{% else %} {% endfor %}
</div>
</div>
<div class="docSidebarTabPanel" id="docTabPanelLayout" role="tabpanel" aria-labelledby="docTabLayout">
{% for category_key, category in element_library.items() %}
<div>
<div class="docSectionLabel">{{ category.label }}</div>
<div class="docLibList"> <div class="docLibList">
{% for kind in category.kinds %} {% for kind in category.kinds %}
<div class="docLibItem docLibItem--row" <div class="docLibItem docLibItem--row"
@@ -120,21 +202,32 @@
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
{% endif %} </div>
</div> {% endfor %}
{% endfor %} </div>
</div> </div>
<div class="docCanvasArea" id="docCanvasArea"> <div class="docCanvasArea" id="docCanvasArea">
<div class="docPage" id="docPage"> <div class="docPage" id="docPage">
<div class="docPageContent" id="docPageContent">{{ rendered_document|safe }}</div> <div class="docPageContent"
{# //NOSONAR S5247 - rendered_document vient de document_engine.render_document, qui échappe (html.escape) tout contenu utilisateur avant interpolation (voir document_engine/rendering/render_document_element.py) ; jamais de HTML brut non échappé ici #} id="docPageContent"
<div class="docSnapGridOverlay" aria-hidden="true"></div> data-vertical-align="{{ active_page.vertical_align if active_page else 'top' }}">{% if active_page %}{{ rendered_document|safe }}{% else %}<div class="docEmptyState docEmptyPageState">Aucune page — cliquez sur « + Ajouter une page » pour commencer.</div>{% endif %}</div>
{# //NOSONAR S5247 - rendered_document vient de document_engine.render_document, qui échappe (html.escape) tout contenu utilisateur avant interpolation (voir document_engine/rendering/render_document_element.py) ; jamais de HTML brut non échappé ici. Le message "Aucune page" est un littéral, jamais un contenu utilisateur. #}
</div> </div>
<button type="button"
class="docPreviewExitBtn"
id="docPreviewExitBtn"
aria-label="Retour à l'éditeur"
title="Retour à l'éditeur">✕ Retour à l'éditeur</button>
<div class="docZoomPill"> <div class="docZoomPill">
<button type="button" id="docZoomOut" aria-label="Zoom -">−</button> <button type="button" id="docZoomOut" aria-label="Zoom -">−</button>
<span id="docZoomVal">100%</span> <span id="docZoomVal">100%</span>
<button type="button" id="docZoomIn" aria-label="Zoom +">+</button> <button type="button" id="docZoomIn" aria-label="Zoom +">+</button>
</div> </div>
<div class="docPageNavPill" id="docPageNavPill">
<button type="button" id="docPagePrevBtn" aria-label="Page précédente">‹</button>
<span id="docPageNavLabel"></span>
<button type="button" id="docPageNextBtn" aria-label="Page suivante">›</button>
</div>
</div> </div>
<div class="docSidebarClose" id="docCloseRight">← Retour au document</div> <div class="docSidebarClose" id="docCloseRight">← Retour au document</div>
<div class="docSidebar docSidebarRight" id="docSidebarRight"> <div class="docSidebar docSidebarRight" id="docSidebarRight">
@@ -150,11 +243,55 @@
<button type="button" data-target="right">Propriétés</button> <button type="button" data-target="right">Propriétés</button>
</div> </div>
</div> </div>
<div class="docModalBackdrop" id="docScenarioTreeModal">
<div class="docModalDialog">
<div class="docModalHeader">
<span class="docModalTitle" id="docScenarioTreeModalTitle">Arbre du scénario</span>
<button type="button"
class="docModalCloseBtn"
id="docScenarioTreeModalClose"
aria-label="Fermer">✕</button>
</div>
<div class="docModalBody" id="docScenarioTreeModalBody"></div>
</div>
</div>
<div class="docModalBackdrop" id="docTemplateModal">
<div class="docModalDialog docModalDialog--wide">
<div class="docModalHeader">
<span class="docModalTitle">Utiliser un modèle</span>
<button type="button"
class="docModalCloseBtn"
id="docTemplateModalClose"
aria-label="Fermer">✕</button>
</div>
<div class="docModalBody docTemplateModalBody">
<div class="docTemplateList" id="docTemplateList"></div>
<div class="docTemplatePreviewPane">
<iframe class="docTemplatePreviewFrame"
id="docTemplatePreviewFrame"
title="Aperçu du modèle"></iframe>
<div class="docTemplatePreviewEmpty"
id="docTemplatePreviewEmpty">Sélectionnez un modèle pour le visualiser.</div>
<div class="docTemplatePreviewActions"
id="docTemplatePreviewActions"
hidden>
<button type="button" class="docBtnSecondary" id="docTemplateKeepContentBtn">Utiliser mon contenu actuel</button>
<button type="button" class="docBtnSecondary docBtnPrimary" id="docTemplateUseContentBtn">Utiliser le contenu du modèle</button>
<button type="button" class="docBtnSecondary docBtnPrimary" id="docTemplateRemoveBtn" hidden>Retirer le modèle</button>
</div>
</div>
</div>
</div>
</div>
<script> <script>
window.FORGE_DOCUMENT = { window.FORGE_DOCUMENT = {
slug: {{ support.slug|tojson }}, slug: {{ support.slug|tojson }},
elements: {{ elements|tojson }}, elements: {{ elements|tojson }},
elementKindLabels: {{ element_kind_labels|tojson }} elementKindLabels: {{ element_kind_labels|tojson }},
pages: {{ pages|tojson }},
activePageId: {{ (active_page.id if active_page else none)|tojson }},
themes: {{ document_themes|tojson }},
activeThemeId: {{ support.theme|tojson }}
}; };
</script> </script>
<script src="{{ url_for('static', filename='document/js/document-editor.js') }}"></script> <script src="{{ url_for('static', filename='document/js/document-editor.js') }}"></script>
@@ -0,0 +1,158 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<title>Aperçu — {{ theme.name }}</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="{{ url_for('static', filename='document/document-editor.css') }}">
{% if theme.font_url %}
<link rel="stylesheet" href="{{ theme.font_url }}">
{% endif %}
<link rel="stylesheet" href="{{ url_for('static', filename=theme.css_path) }}">
<style>
html, body {
margin: 0;
padding: 0;
height: 100%;
background: #efece4;
display: flex;
flex-direction: column;
overflow: hidden;
}
.previewPages {
flex: 1;
min-height: 0;
min-width: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
overflow: hidden;
}
/* La page réelle a une largeur/un ratio FIXES côté moteur
(--doc-page-width/aspect-ratio, voir static/document/
document-editor.css) — sans mise à l'échelle, elle déborde
verticalement de cette fenêtre d'aperçu plus petite qu'un canevas
d'édition en plein écran, et défile/rogne au lieu de tenir
entière (retour utilisateur du 24/09/2026). `forgeDocFitPreviewPage`
calcule un facteur d'échelle qui la fait TOUJOURS tenir
entièrement, sans barre de défilement. */
.previewPages .docPage {
flex-shrink: 0;
transform-origin: center center;
}
.previewPages .docPage[hidden] {
display: none;
}
.previewNav {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
gap: 14px;
padding: 10px;
background: #1a1b20;
color: #fff;
font: 600 13px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
.previewNav button {
border: none;
background: #2a2c33;
color: #fff;
border-radius: 6px;
padding: 6px 12px;
cursor: pointer;
font: inherit;
}
.previewNav button:disabled {
opacity: 0.4;
cursor: default;
}
</style>
</head>
<body>
<div class="previewPages">
{% for page in pages %}
<div class="docPage" data-page-index="{{ loop.index0 }}" {% if not loop.first %}hidden{% endif %}>
<div class="docPageContent" data-vertical-align="{{ page.vertical_align }}">
{{ page.html | safe }}
</div>
</div>
{% endfor %}
</div>
{% if pages|length > 1 %}
<div class="previewNav">
<button type="button" id="previewPrevBtn">‹ Précédent</button>
<span id="previewPageLabel"></span>
<button type="button" id="previewNextBtn">Suivant ›</button>
</div>
{% endif %}
<script>
(function previewPageNav() {
var pages = Array.prototype.slice.call(document.querySelectorAll('.previewPages .docPage'));
if (pages.length <= 1) return;
var current = 0;
var prevBtn = document.getElementById('previewPrevBtn');
var nextBtn = document.getElementById('previewNextBtn');
var label = document.getElementById('previewPageLabel');
function render() {
pages.forEach(function pageAt(page, index) {
page.hidden = index !== current;
});
label.textContent = 'Page ' + (current + 1) + ' / ' + pages.length;
prevBtn.disabled = current === 0;
nextBtn.disabled = current === pages.length - 1;
}
prevBtn.addEventListener('click', function goPrev() {
if (current > 0) { current -= 1; render(); }
});
nextBtn.addEventListener('click', function goNext() {
if (current < pages.length - 1) { current += 1; render(); }
});
render();
}());
(function fitPreviewPage() {
// Toutes les pages partagent EXACTEMENT la même largeur/le même ratio
// fixes côté moteur (--doc-page-width/aspect-ratio) quel que soit leur
// contenu (voir static/document/document-editor.css) : mesurer une
// seule page suffit à connaître la taille "naturelle" de toutes.
var container = document.querySelector('.previewPages');
var pages = Array.prototype.slice.call(document.querySelectorAll('.previewPages .docPage'));
if (!container || !pages.length) return;
var reference = pages[0];
function applyFit() {
var wasHidden = reference.hidden;
var previousTransform = reference.style.transform;
reference.hidden = false;
reference.style.transform = 'none';
var naturalWidth = reference.offsetWidth;
var naturalHeight = reference.offsetHeight;
reference.hidden = wasHidden;
reference.style.transform = previousTransform;
if (!naturalWidth || !naturalHeight) return;
var availableWidth = container.clientWidth - 32;
var availableHeight = container.clientHeight - 32;
// Jamais agrandie au-delà de sa taille réelle (max 1) — un simple
// rétrécissement pour tenir, jamais un zoom artificiel.
var scale = Math.min(availableWidth / naturalWidth, availableHeight / naturalHeight, 1);
pages.forEach(function scalePage(page) {
page.style.transform = 'scale(' + scale + ')';
});
}
applyFit();
window.addEventListener('resize', applyFit);
}());
</script>
</body>
</html>
+11 -1
View File
@@ -205,12 +205,22 @@ def support(client: FlaskClient) -> Generator[str, None, None]:
"""Crée un support de formation de test frais via /documents/new """Crée un support de formation de test frais via /documents/new
(mirroir de la fixture `game` ci-dessus, pour l'autre type de projet — (mirroir de la fixture `game` ci-dessus, pour l'autre type de projet —
entité racine séparée, voir docs/plan/PLAN.md) et le supprime après le entité racine séparée, voir docs/plan/PLAN.md) et le supprime après le
test, quel que soit son résultat.""" test, quel que soit son résultat.
Un support neuf n'a plus aucune page par défaut (retour utilisateur
du 26/09/2026, voir document_engine/pages/pages.md) — cette fixture
lui en crée une par convénience, puisque la quasi-totalité des tests
existants portent sur du contenu et présupposent une première page
"Page 1" déjà là (comme avant ce changement). Les tests qui portent
spécifiquement sur l'état "0 page" créent leur propre support brut
via db.create_support(...) plutôt que d'utiliser cette fixture."""
import db.supports import db.supports
import document_engine
resp = client.post("/documents/new", data={"name": "pytest_test_support"}, follow_redirects=False) resp = client.post("/documents/new", data={"name": "pytest_test_support"}, follow_redirects=False)
assert resp.status_code == 302 assert resp.status_code == 302
slug = resp.headers["Location"].rstrip("/").split("/")[-2] slug = resp.headers["Location"].rstrip("/").split("/")[-2]
document_engine.add_document_page(slug)
yield slug yield slug
if os.path.isdir(db.supports.support_dir(slug)): if os.path.isdir(db.supports.support_dir(slug)):
db.delete_support(slug) db.delete_support(slug)
+96
View File
@@ -0,0 +1,96 @@
"""Attributs de "boîte" partagés par plusieurs kinds de contenu (voir
document_engine/rendering/box_style.py) — testés une seule fois ici,
indépendamment de chaque kind qui les utilise (voir aussi
test_document_elements.py pour leur usage réel via _render_text)."""
from document_engine.rendering.box_style import BORDER_SIDES, default_border, render_box_style, render_content_align
def test_default_border_has_all_four_sides_set_to_none() -> None:
border = default_border()
assert set(border.keys()) == set(BORDER_SIDES)
for side in BORDER_SIDES:
assert border[side]["style"] == "none"
def test_default_border_returns_a_fresh_dict_each_call() -> None:
"""Jamais un littéral partagé muté par référence entre deux éléments
(même précaution que DEFAULT_QUIZ_CONFIG côté labels)."""
a = default_border()
b = default_border()
a["top"]["style"] = "solid"
assert b["top"]["style"] == "none"
def test_render_box_style_returns_empty_string_for_all_defaults() -> None:
assert render_box_style({}) == ""
assert render_box_style({"border": default_border(), "align_self": "stretch"}) == ""
def test_render_box_style_includes_padding_margin_background_and_radius() -> None:
style = render_box_style(
{"padding": "12px", "margin": "0 auto", "background_color": "#eee", "border_radius": "8px"}
)
assert "padding:12px;" in style
assert "margin:0 auto;" in style
assert "background-color:#eee;" in style
assert "border-radius:8px;" in style
def test_render_box_style_includes_sizing_shadow_and_opacity() -> None:
style = render_box_style(
{
"height": "200px",
"min_height": "100px",
"max_height": "400px",
"min_width": "50px",
"box_shadow": "0 4px 12px rgba(0,0,0,.2)",
"opacity": "0.8",
}
)
assert "height:200px;" in style
assert "min-height:100px;" in style
assert "max-height:400px;" in style
assert "min-width:50px;" in style
assert "box-shadow:0 4px 12px rgba(0,0,0,.2);" in style
assert "opacity:0.8;" in style
def test_render_box_style_includes_align_self_only_when_not_stretch() -> None:
assert "align-self" not in render_box_style({"align_self": "stretch"})
assert "align-self:center;" in render_box_style({"align_self": "center"})
def test_render_box_style_renders_only_sides_with_a_non_none_style() -> None:
border = default_border()
border["top"] = {"style": "solid", "width": "2px", "color": "#ff0000"}
style = render_box_style({"border": border})
assert "border-top:2px solid #ff0000;" in style
assert "border-right" not in style
assert "border-bottom" not in style
assert "border-left" not in style
def test_render_box_style_escapes_malicious_values() -> None:
border = default_border()
border["top"] = {"style": "solid", "width": "1px", "color": '"><script>alert(1)</script>'}
style = render_box_style({"padding": '"><script>alert(2)</script>', "border": border})
assert "<script>" not in style
def test_render_content_align_is_empty_for_default_top() -> None:
# Retour utilisateur du 26/09/2026 : "je peux augmenter la hauteur
# d'un conteneur mais pas l'alignement vertical à l'intérieur" —
# "top" est le comportement historique (contenu en haut), jamais un
# style ajouté pour ne rien changer par défaut.
assert render_content_align({}) == ""
assert render_content_align({"content_align": "top"}) == ""
def test_render_content_align_renders_center_and_bottom() -> None:
assert render_content_align({"content_align": "center"}) == "justify-content:center;"
assert render_content_align({"content_align": "bottom"}) == "justify-content:flex-end;"
def test_render_content_align_ignores_unknown_values() -> None:
assert render_content_align({"content_align": "n-importe-quoi"}) == ""
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,145 @@
"""Routes HTTP des pages d'un support de formation (routes/document/
document_page_*.py) — retour utilisateur du 21/09/2026 : "il faut
implémenter un système de page". La fixture `support` crée une première
page par convénience pour ces tests (voir tests/conftest.py) ; en
production un support neuf n'a plus aucune page, et supprimer la
dernière page restante est désormais autorisé (retour utilisateur du
26/09/2026, voir document_engine/pages/pages.md)."""
from flask.testing import FlaskClient
import document_engine
def _page_id(support: str) -> int:
return int(document_engine.list_document_pages(support)[0]["id"])
def test_document_page_add_creates_a_second_page(client: FlaskClient, support: str) -> None:
resp = client.post(f"/document/{support}/pages/add", json={})
assert resp.status_code == 200
payload = resp.get_json()
assert payload["title"] == "Page 2"
pages = document_engine.list_document_pages(support)
assert [p["title"] for p in pages] == ["Page 1", "Page 2"]
assert pages[1]["id"] == payload["id"]
def test_document_page_add_accepts_a_custom_title(client: FlaskClient, support: str) -> None:
resp = client.post(f"/document/{support}/pages/add", json={"title": "Chapitre 2"})
assert resp.status_code == 200
assert resp.get_json()["title"] == "Chapitre 2"
def test_document_page_rename_persists_the_new_title(client: FlaskClient, support: str) -> None:
page_id = _page_id(support)
resp = client.post(f"/document/{support}/pages/{page_id}/rename", json={"title": "Introduction"})
assert resp.status_code == 200
assert resp.get_json() == {"ok": True, "title": "Introduction"}
page = document_engine.get_document_page(support, page_id)
assert page is not None
assert page["title"] == "Introduction"
def test_document_page_rename_ignores_an_empty_title(client: FlaskClient, support: str) -> None:
page_id = _page_id(support)
resp = client.post(f"/document/{support}/pages/{page_id}/rename", json={"title": " "})
assert resp.status_code == 200
assert resp.get_json()["title"] == "Page 1"
def test_document_page_rename_rejects_an_unknown_page(client: FlaskClient, support: str) -> None:
assert client.post(f"/document/{support}/pages/999/rename", json={"title": "X"}).status_code == 404
def test_document_page_delete_allows_deleting_the_last_remaining_page(client: FlaskClient, support: str) -> None:
page_id = _page_id(support)
resp = client.post(f"/document/{support}/pages/{page_id}/delete")
assert resp.status_code == 200
assert resp.get_json()["ok"] is True
assert document_engine.get_document_page(support, page_id) is None
assert document_engine.list_document_pages(support) == []
def test_document_page_delete_all_wipes_every_page_and_its_elements(client: FlaskClient, support: str) -> None:
first_page_id = _page_id(support)
second_page_id = client.post(f"/document/{support}/pages/add", json={}).get_json()["id"]
element_resp = client.post(f"/document/{support}/elements/add", data={"kind": "titre", "page_id": second_page_id})
element_id = element_resp.get_json()["id"]
resp = client.post(f"/document/{support}/pages/delete-all")
assert resp.status_code == 200
assert resp.get_json()["ok"] is True
assert document_engine.list_document_pages(support) == []
assert document_engine.get_document_page(support, first_page_id) is None
assert document_engine.get_document_page(support, second_page_id) is None
assert document_engine.get_document_element(support, element_id) is None
def test_document_page_delete_removes_a_non_last_page_and_its_elements(client: FlaskClient, support: str) -> None:
first_page_id = _page_id(support)
second_page_id = client.post(f"/document/{support}/pages/add", json={}).get_json()["id"]
element_resp = client.post(f"/document/{support}/elements/add", data={"kind": "titre", "page_id": second_page_id})
element_id = element_resp.get_json()["id"]
resp = client.post(f"/document/{support}/pages/{second_page_id}/delete")
assert resp.status_code == 200
assert resp.get_json()["ok"] is True
assert document_engine.get_document_page(support, second_page_id) is None
assert document_engine.get_document_element(support, element_id) is None
assert [p["id"] for p in document_engine.list_document_pages(support)] == [first_page_id]
def test_document_page_delete_rejects_an_unknown_page(client: FlaskClient, support: str) -> None:
client.post(f"/document/{support}/pages/add", json={})
assert client.post(f"/document/{support}/pages/999/delete").status_code == 404
def test_document_page_move_reorders_the_tab_strip(client: FlaskClient, support: str) -> None:
first_page_id = _page_id(support)
second_page_id = client.post(f"/document/{support}/pages/add", json={}).get_json()["id"]
third_page_id = client.post(f"/document/{support}/pages/add", json={}).get_json()["id"]
resp = client.post(f"/document/{support}/pages/{third_page_id}/move", json={"new_index": 0})
assert resp.status_code == 200
assert resp.get_json()["ok"] is True
ordered_ids = [p["id"] for p in document_engine.list_document_pages(support)]
assert ordered_ids == [third_page_id, first_page_id, second_page_id]
def test_document_page_move_rejects_an_unknown_page(client: FlaskClient, support: str) -> None:
assert client.post(f"/document/{support}/pages/999/move", json={"new_index": 0}).status_code == 404
def test_new_page_defaults_to_top_vertical_align(client: FlaskClient, support: str) -> None:
page_id = _page_id(support)
page = document_engine.get_document_page(support, page_id)
assert page is not None
assert page["vertical_align"] == "top"
def test_document_page_vertical_align_persists_a_valid_value(client: FlaskClient, support: str) -> None:
page_id = _page_id(support)
resp = client.post(f"/document/{support}/pages/{page_id}/vertical-align", json={"vertical_align": "center"})
assert resp.status_code == 200
assert resp.get_json() == {"ok": True, "vertical_align": "center"}
page = document_engine.get_document_page(support, page_id)
assert page is not None
assert page["vertical_align"] == "center"
def test_document_page_vertical_align_falls_back_to_top_for_an_invalid_value(client: FlaskClient, support: str) -> None:
page_id = _page_id(support)
client.post(f"/document/{support}/pages/{page_id}/vertical-align", json={"vertical_align": "center"})
resp = client.post(f"/document/{support}/pages/{page_id}/vertical-align", json={"vertical_align": "n-importe-quoi"})
assert resp.status_code == 200
assert resp.get_json()["vertical_align"] == "top"
def test_document_page_vertical_align_rejects_an_unknown_page(client: FlaskClient, support: str) -> None:
resp = client.post(f"/document/{support}/pages/999/vertical-align", json={"vertical_align": "center"})
assert resp.status_code == 404
+448 -10
View File
@@ -1,13 +1,25 @@
"""Routes HTTP du support de formation (routes/document/) — création, """Routes HTTP du support de formation (routes/document/) — création,
édition directe (sans page intermédiaire), CRUD des éléments en AJAX, édition directe (sans page intermédiaire), CRUD des éléments en AJAX,
publication, et isolation par propriétaire (core/auth_guard.py).""" publication, et isolation par propriétaire (core/auth_guard.py).
Chaque élément appartient désormais à une page (voir document_engine/
pages/ et test_document_pages_routes.py pour les routes dédiées aux
pages elles-mêmes) — _page_id() renvoie l'id de la page par défaut
("Page 1") créée avec chaque support de test, réutilisé par toutes les
routes d'élément ci-dessous qui exigent désormais un page_id explicite."""
import io
from typing import Any from typing import Any
from flask.testing import FlaskClient from flask.testing import FlaskClient
import auth import auth
import db import db
import document_engine
def _page_id(support: str) -> int:
return int(document_engine.list_document_pages(support)[0]["id"])
def test_documents_new_creates_a_support_not_a_game(client: FlaskClient) -> None: def test_documents_new_creates_a_support_not_a_game(client: FlaskClient) -> None:
@@ -27,8 +39,21 @@ def test_document_edit_renders_directly_no_intermediate_page(client: FlaskClient
assert resp.status_code == 200 assert resp.status_code == 200
def test_document_edit_works_on_a_support_with_zero_pages(client: FlaskClient, support: str) -> None:
# Retour utilisateur du 26/09/2026 : "l'éditeur ne dois plus etre
# obliger d'avoir une page active ou créer, il peut etre ouvert sans
# aucune page" — un support neuf (ou vidé via "Supprimer toutes les
# pages") n'a plus de page active, l'éditeur ne doit pas planter.
client.post(f"/document/{support}/pages/delete-all")
resp = client.get(f"/document/{support}/edit")
assert resp.status_code == 200
html = resp.get_data(as_text=True)
assert "activePageId: null" in html
assert "pages: []" in html
def test_document_element_add_returns_rendered_html(client: FlaskClient, support: str) -> None: def test_document_element_add_returns_rendered_html(client: FlaskClient, support: str) -> None:
resp = client.post(f"/document/{support}/elements/add", data={"kind": "titre"}) resp = client.post(f"/document/{support}/elements/add", data={"kind": "titre", "page_id": _page_id(support)})
assert resp.status_code == 200 assert resp.status_code == 200
payload = resp.get_json() payload = resp.get_json()
assert payload["kind"] == "titre" assert payload["kind"] == "titre"
@@ -36,7 +61,15 @@ def test_document_element_add_returns_rendered_html(client: FlaskClient, support
def test_document_element_add_rejects_unknown_kind(client: FlaskClient, support: str) -> None: def test_document_element_add_rejects_unknown_kind(client: FlaskClient, support: str) -> None:
assert client.post(f"/document/{support}/elements/add", data={"kind": "n-importe-quoi"}).status_code == 400 resp = client.post(
f"/document/{support}/elements/add", data={"kind": "n-importe-quoi", "page_id": _page_id(support)}
)
assert resp.status_code == 400
def test_document_element_add_rejects_a_missing_or_unknown_page(client: FlaskClient, support: str) -> None:
assert client.post(f"/document/{support}/elements/add", data={"kind": "titre"}).status_code == 404
assert client.post(f"/document/{support}/elements/add", data={"kind": "titre", "page_id": 999}).status_code == 404
def test_document_element_add_accepts_row_for_the_layout_engine(client: FlaskClient, support: str) -> None: def test_document_element_add_accepts_row_for_the_layout_engine(client: FlaskClient, support: str) -> None:
@@ -45,13 +78,33 @@ def test_document_element_add_accepts_row_for_the_layout_engine(client: FlaskCli
route — c'est le moteur de layout côté client qui en crée une à la route — c'est le moteur de layout côté client qui en crée une à la
volée au moment d'un dépôt latéral (voir volée au moment d'un dépôt latéral (voir
static/document/js/document-editor.js).""" static/document/js/document-editor.js)."""
resp = client.post(f"/document/{support}/elements/add", data={"kind": "row"}) resp = client.post(f"/document/{support}/elements/add", data={"kind": "row", "page_id": _page_id(support)})
assert resp.status_code == 200 assert resp.status_code == 200
assert resp.get_json()["kind"] == "row" assert resp.get_json()["kind"] == "row"
def test_document_element_add_creates_a_bullet_list(client: FlaskClient, support: str) -> None:
resp = client.post(f"/document/{support}/elements/add", data={"kind": "liste_puces", "page_id": _page_id(support)})
assert resp.status_code == 200
payload = resp.get_json()
assert payload["kind"] == "liste_puces"
assert "<ul" in payload["rendered_html"]
assert "docList" in payload["rendered_html"]
def test_document_element_add_creates_a_numbered_list(client: FlaskClient, support: str) -> None:
resp = client.post(
f"/document/{support}/elements/add", data={"kind": "liste_numerotee", "page_id": _page_id(support)}
)
assert resp.status_code == 200
payload = resp.get_json()
assert payload["kind"] == "liste_numerotee"
assert "<ol" in payload["rendered_html"]
def test_document_element_update_persists_attributes(client: FlaskClient, support: str) -> None: def test_document_element_update_persists_attributes(client: FlaskClient, support: str) -> None:
add_resp = client.post(f"/document/{support}/elements/add", data={"kind": "paragraphe"}) page_id = _page_id(support)
add_resp = client.post(f"/document/{support}/elements/add", data={"kind": "paragraphe", "page_id": page_id})
element_id = add_resp.get_json()["id"] element_id = add_resp.get_json()["id"]
resp = client.post( resp = client.post(
@@ -61,7 +114,7 @@ def test_document_element_update_persists_attributes(client: FlaskClient, suppor
assert resp.status_code == 200 assert resp.status_code == 200
assert resp.get_json()["ok"] is True assert resp.get_json()["ok"] is True
reread = client.post(f"/document/{support}/elements/add", data={"kind": "titre"}) reread = client.post(f"/document/{support}/elements/add", data={"kind": "titre", "page_id": page_id})
assert reread.status_code == 200 assert reread.status_code == 200
@@ -71,7 +124,7 @@ def test_document_element_update_sanitizes_quiz_config_on_write(client: FlaskCli
(5e) doit être tronqué, et la réponse renvoie les attributs (5e) doit être tronqué, et la réponse renvoie les attributs
RÉELLEMENT persistés (pas ceux envoyés tels quels) pour que le RÉELLEMENT persistés (pas ceux envoyés tels quels) pour que le
client ne dérive jamais de la vérité serveur.""" client ne dérive jamais de la vérité serveur."""
add_resp = client.post(f"/document/{support}/elements/add", data={"kind": "quiz"}) add_resp = client.post(f"/document/{support}/elements/add", data={"kind": "quiz", "page_id": _page_id(support)})
element_id = add_resp.get_json()["id"] element_id = add_resp.get_json()["id"]
resp = client.post( resp = client.post(
@@ -93,7 +146,9 @@ def test_document_element_update_sanitizes_quiz_config_on_write(client: FlaskCli
def test_document_element_update_sanitizes_association_config_on_write(client: FlaskClient, support: str) -> None: def test_document_element_update_sanitizes_association_config_on_write(client: FlaskClient, support: str) -> None:
add_resp = client.post(f"/document/{support}/elements/add", data={"kind": "association"}) add_resp = client.post(
f"/document/{support}/elements/add", data={"kind": "association", "page_id": _page_id(support)}
)
element_id = add_resp.get_json()["id"] element_id = add_resp.get_json()["id"]
resp = client.post( resp = client.post(
@@ -113,9 +168,137 @@ def test_document_element_update_sanitizes_association_config_on_write(client: F
assert "1 paire" in payload["rendered_html"] assert "1 paire" in payload["rendered_html"]
def test_document_element_update_sanitizes_memory_config_on_write(client: FlaskClient, support: str) -> None:
add_resp = client.post(f"/document/{support}/elements/add", data={"kind": "memory", "page_id": _page_id(support)})
element_id = add_resp.get_json()["id"]
resp = client.post(
f"/document/{support}/elements/{element_id}/update",
json={
"theme_color": "#ffb020",
"mode": "single",
"cards": [
{"recto": {"text": "?"}, "verso": {"text": "Chat"}},
{"recto": {"text": "?"}, "verso": {"image": "", "text": ""}},
],
},
)
assert resp.status_code == 200
payload = resp.get_json()
assert payload["ok"] is True
assert payload["attributes"]["mode"] == "single"
assert len(payload["attributes"]["cards"]) == 1
assert "1 carte" in payload["rendered_html"]
assert "mode simple" in payload["rendered_html"]
def test_document_edit_sanitizes_stale_scenario_attributes_from_an_old_schema(
client: FlaskClient, support: str
) -> None:
"""Bug réel constaté le 21/09/2026 : un élément Scénario créé AVANT la
refonte en arbre de décision (ancien schéma plat "situation"/"choices"/
"correct_index", sans "title"/"nodes") faisait planter silencieusement
le panneau Propriétés côté client (`scenario.nodes` inexistant), sans
qu'aucune erreur ne remonte — voir document_engine/labels/
sanitize_element_attributes.py. `update_document_element_attributes`
(bas niveau, jamais utilisée directement par une route) sert ici à
écrire ce schéma obsolète tel quel, en contournant volontairement le
sanitize de la route d'update — pour simuler une ligne réellement
ancienne en base, jamais nettoyée depuis."""
page_id = _page_id(support)
add_resp = client.post(f"/document/{support}/elements/add", data={"kind": "scenario", "page_id": page_id})
element_id = add_resp.get_json()["id"]
stale_attributes = {
"theme_color": "#ff5f2e",
"scenarios": [
{
"situation": "Un chat va se faire renverser sous vos yeux, que faites-vous ?",
"choices": [{"text": "Je le sauve", "consequence": "Le chat est sauvé."}],
"correct_index": 0,
}
],
}
document_engine.update_document_element_attributes(support, element_id, stale_attributes)
edit_resp = client.get(f"/document/{support}/edit")
assert edit_resp.status_code == 200
render_resp = client.get(f"/document/{support}/render", query_string={"page_id": page_id})
assert render_resp.status_code == 200
rendered_element = next(el for el in render_resp.get_json()["elements"] if el["id"] == element_id)
# Le vieux schéma n'a ni "title" ni "nodes" valides : sanitize_scenario_config
# le rejette entièrement plutôt que de renvoyer une structure à moitié
# ancienne/à moitié nouvelle — c'est la structure ACTUELLE garantie qui
# compte ici, jamais un plantage silencieux côté client.
assert rendered_element["attributes"] == document_engine.DEFAULT_SCENARIO_CONFIG
def test_document_render_rejects_a_missing_or_unknown_page(client: FlaskClient, support: str) -> None:
assert client.get(f"/document/{support}/render").status_code == 404
assert client.get(f"/document/{support}/render", query_string={"page_id": 999}).status_code == 404
def test_document_render_includes_the_page_vertical_align(client: FlaskClient, support: str) -> None:
page_id = _page_id(support)
client.post(f"/document/{support}/pages/{page_id}/vertical-align", json={"vertical_align": "center"})
resp = client.get(f"/document/{support}/render", query_string={"page_id": page_id})
assert resp.status_code == 200
assert resp.get_json()["vertical_align"] == "center"
def test_document_edit_reflects_the_active_page_vertical_align(client: FlaskClient, support: str) -> None:
page_id = _page_id(support)
client.post(f"/document/{support}/pages/{page_id}/vertical-align", json={"vertical_align": "center"})
resp = client.get(f"/document/{support}/edit")
assert 'data-vertical-align="center"' in resp.get_data(as_text=True)
def test_document_element_add_rejects_a_minigame_on_a_non_empty_page(client: FlaskClient, support: str) -> None:
"""Retour utilisateur du 23/09/2026 : "un mini jeu dois occupper
toute une page" -> "une page avec mini-jeu = uniquement ce
mini-jeu". Un mini-jeu ne peut donc jamais rejoindre une page qui a
déjà du contenu."""
page_id = _page_id(support)
client.post(f"/document/{support}/elements/add", data={"kind": "titre", "page_id": page_id})
resp = client.post(f"/document/{support}/elements/add", data={"kind": "quiz", "page_id": page_id})
assert resp.status_code == 400
assert "mini-jeu" in resp.get_json()["error"]
def test_document_element_add_rejects_other_content_on_a_page_with_a_minigame(
client: FlaskClient, support: str
) -> None:
page_id = _page_id(support)
client.post(f"/document/{support}/elements/add", data={"kind": "quiz", "page_id": page_id})
resp = client.post(f"/document/{support}/elements/add", data={"kind": "titre", "page_id": page_id})
assert resp.status_code == 400
assert "mini-jeu" in resp.get_json()["error"]
def test_document_element_add_rejects_a_minigame_inside_a_row(client: FlaskClient, support: str) -> None:
page_id = _page_id(support)
row_id = client.post(f"/document/{support}/elements/add", data={"kind": "row", "page_id": page_id}).get_json()["id"]
resp = client.post(
f"/document/{support}/elements/add", data={"kind": "quiz", "page_id": page_id, "parent_id": row_id}
)
assert resp.status_code == 400
def test_document_element_add_allows_a_lone_minigame_on_an_empty_page(client: FlaskClient, support: str) -> None:
resp = client.post(f"/document/{support}/elements/add", data={"kind": "quiz", "page_id": _page_id(support)})
assert resp.status_code == 200
def test_document_element_move_and_delete(client: FlaskClient, support: str) -> None: def test_document_element_move_and_delete(client: FlaskClient, support: str) -> None:
first_id = client.post(f"/document/{support}/elements/add", data={"kind": "titre"}).get_json()["id"] page_id = _page_id(support)
second_id = client.post(f"/document/{support}/elements/add", data={"kind": "paragraphe"}).get_json()["id"] first_id = client.post(f"/document/{support}/elements/add", data={"kind": "titre", "page_id": page_id}).get_json()[
"id"
]
second_id = client.post(
f"/document/{support}/elements/add", data={"kind": "paragraphe", "page_id": page_id}
).get_json()["id"]
move_resp = client.post( move_resp = client.post(
f"/document/{support}/elements/{second_id}/move", f"/document/{support}/elements/{second_id}/move",
@@ -129,6 +312,35 @@ def test_document_element_move_and_delete(client: FlaskClient, support: str) ->
assert delete_resp.get_json()["ok"] is True assert delete_resp.get_json()["ok"] is True
def test_document_element_move_to_page_transfers_the_element(client: FlaskClient, support: str) -> None:
"""Retour utilisateur du 23/09/2026 : pagination automatique — un
élément qui déborde d'une page est déplacé vers une autre, ici la
route qui porte ce déplacement (jamais document_element_move, qui
ne fait que réordonner DANS la même page)."""
page1_id = _page_id(support)
page2_id = document_engine.add_document_page(support)
element_id = client.post(
f"/document/{support}/elements/add", data={"kind": "titre", "page_id": page1_id}
).get_json()["id"]
resp = client.post(f"/document/{support}/elements/{element_id}/move-to-page", json={"target_page_id": page2_id})
assert resp.status_code == 200
assert resp.get_json()["ok"] is True
moved = document_engine.get_document_element(support, element_id)
assert moved is not None
assert moved["page_id"] == page2_id
def test_document_element_move_to_page_rejects_an_unknown_target(client: FlaskClient, support: str) -> None:
page_id = _page_id(support)
element_id = client.post(
f"/document/{support}/elements/add", data={"kind": "titre", "page_id": page_id}
).get_json()["id"]
resp = client.post(f"/document/{support}/elements/{element_id}/move-to-page", json={"target_page_id": 999})
assert resp.status_code == 404
def test_restricted_user_can_have_one_game_and_one_support_at_once( def test_restricted_user_can_have_one_game_and_one_support_at_once(
user_client: FlaskClient, tmp_game_slug_cleanup: Any, tmp_support_slug_cleanup: Any user_client: FlaskClient, tmp_game_slug_cleanup: Any, tmp_support_slug_cleanup: Any
) -> None: ) -> None:
@@ -161,3 +373,229 @@ def test_cannot_open_another_owners_support(client: FlaskClient, user_client: Fl
assert client.post(f"/document/{victim_slug}/delete").status_code == 403 assert client.post(f"/document/{victim_slug}/delete").status_code == 403
finally: finally:
db.delete_support(victim_slug) db.delete_support(victim_slug)
def _add_bouton(client: FlaskClient, support: str) -> int:
resp = client.post(f"/document/{support}/elements/add", data={"kind": "bouton", "page_id": _page_id(support)})
return int(resp.get_json()["id"])
def test_upload_attachment_stores_file_and_updates_button_attributes(client: FlaskClient, support: str) -> None:
element_id = _add_bouton(client, support)
resp = client.post(
f"/document/{support}/elements/{element_id}/upload-attachment",
data={"file": (io.BytesIO(b"%PDF-1.4 fake pdf content"), "fiche-consignes.pdf")},
content_type="multipart/form-data",
)
assert resp.status_code == 200
payload = resp.get_json()
assert payload["attributes"]["attachment_filename"] == "fiche-consignes.pdf"
assert payload["attributes"]["attachment_stored_name"]
assert 'data-attachment-filename="fiche-consignes.pdf"' in payload["rendered_html"]
def test_upload_attachment_rejects_a_non_bouton_element(client: FlaskClient, support: str) -> None:
resp = client.post(f"/document/{support}/elements/add", data={"kind": "titre", "page_id": _page_id(support)})
element_id = resp.get_json()["id"]
resp = client.post(
f"/document/{support}/elements/{element_id}/upload-attachment",
data={"file": (io.BytesIO(b"peu importe"), "x.pdf")},
content_type="multipart/form-data",
)
assert resp.status_code == 400
def test_upload_attachment_rejects_a_missing_file(client: FlaskClient, support: str) -> None:
element_id = _add_bouton(client, support)
resp = client.post(f"/document/{support}/elements/{element_id}/upload-attachment", data={})
assert resp.status_code == 400
def test_download_attachment_serves_the_file_under_its_original_name(client: FlaskClient, support: str) -> None:
element_id = _add_bouton(client, support)
client.post(
f"/document/{support}/elements/{element_id}/upload-attachment",
data={"file": (io.BytesIO(b"%PDF-1.4 fake pdf content"), "fiche-consignes.pdf")},
content_type="multipart/form-data",
)
resp = client.get(f"/document/{support}/elements/{element_id}/download-attachment")
assert resp.status_code == 200
assert resp.data == b"%PDF-1.4 fake pdf content"
assert "fiche-consignes.pdf" in resp.headers["Content-Disposition"]
def test_download_attachment_404s_when_nothing_was_uploaded(client: FlaskClient, support: str) -> None:
element_id = _add_bouton(client, support)
resp = client.get(f"/document/{support}/elements/{element_id}/download-attachment")
assert resp.status_code == 404
def _add_image(client: FlaskClient, support: str) -> int:
resp = client.post(f"/document/{support}/elements/add", data={"kind": "image", "page_id": _page_id(support)})
return int(resp.get_json()["id"])
def test_upload_image_sets_src_and_clears_svg_markup(client: FlaskClient, support: str) -> None:
element_id = _add_image(client, support)
document_engine.update_document_element_attributes(support, element_id, {"svg_markup": "<svg></svg>"})
resp = client.post(
f"/document/{support}/elements/{element_id}/upload-image",
data={"file": (io.BytesIO(b"fake png bytes"), "photo.png")},
content_type="multipart/form-data",
)
assert resp.status_code == 200
payload = resp.get_json()
assert payload["ok"] is True
assert payload["attributes"]["svg_markup"] == ""
src = payload["attributes"]["src"]
assert f"/document/{support}/uploads/" in src
assert f'src="{src}"' in payload["rendered_html"]
def test_upload_image_rejects_a_non_image_element(client: FlaskClient, support: str) -> None:
element_id = _add_bouton(client, support)
resp = client.post(
f"/document/{support}/elements/{element_id}/upload-image",
data={"file": (io.BytesIO(b"peu importe"), "x.png")},
content_type="multipart/form-data",
)
assert resp.status_code == 400
def test_upload_image_rejects_a_missing_file(client: FlaskClient, support: str) -> None:
element_id = _add_image(client, support)
resp = client.post(f"/document/{support}/elements/{element_id}/upload-image", data={})
assert resp.status_code == 400
def test_upload_image_rejects_a_disallowed_extension(client: FlaskClient, support: str) -> None:
element_id = _add_image(client, support)
resp = client.post(
f"/document/{support}/elements/{element_id}/upload-image",
data={"file": (io.BytesIO(b"#!/bin/sh"), "script.sh")},
content_type="multipart/form-data",
)
assert resp.status_code == 400
def test_uploaded_file_serves_the_stored_image(client: FlaskClient, support: str) -> None:
element_id = _add_image(client, support)
resp = client.post(
f"/document/{support}/elements/{element_id}/upload-image",
data={"file": (io.BytesIO(b"fake png bytes"), "photo.png")},
content_type="multipart/form-data",
)
src = resp.get_json()["attributes"]["src"]
resp = client.get(src)
assert resp.status_code == 200
assert resp.data == b"fake png bytes"
def test_uploaded_file_404s_for_an_unknown_filename(client: FlaskClient, support: str) -> None:
resp = client.get(f"/document/{support}/uploads/inconnu.png")
assert resp.status_code == 404
def test_document_edit_has_no_theme_link_by_default(client: FlaskClient, support: str) -> None:
resp = client.get(f"/document/{support}/edit")
assert "document/themes/securite-incendie.css" not in resp.get_data(as_text=True)
def test_document_theme_apply_rejects_an_unknown_theme(client: FlaskClient, support: str) -> None:
resp = client.post(
f"/document/{support}/theme/apply",
json={"theme_id": "n-importe-quoi", "mode": "keep_content"},
)
assert resp.status_code == 404
def test_document_theme_apply_rejects_an_invalid_mode(client: FlaskClient, support: str) -> None:
resp = client.post(
f"/document/{support}/theme/apply",
json={"theme_id": "securite-incendie", "mode": "n-importe-quoi"},
)
assert resp.status_code == 400
def test_document_theme_apply_keep_content_only_sets_the_theme(client: FlaskClient, support: str) -> None:
page_id = _page_id(support)
client.post(f"/document/{support}/elements/add", data={"kind": "titre", "page_id": page_id})
resp = client.post(
f"/document/{support}/theme/apply",
json={"theme_id": "securite-incendie", "mode": "keep_content"},
)
assert resp.status_code == 200
assert db.get_document_theme(support) == "securite-incendie"
assert len(document_engine.list_document_elements(support, page_id)) == 1
edit_resp = client.get(f"/document/{support}/edit")
assert "document/themes/securite-incendie.css" in edit_resp.get_data(as_text=True)
def test_document_theme_apply_template_content_replaces_everything(client: FlaskClient, support: str) -> None:
page_id = _page_id(support)
client.post(f"/document/{support}/elements/add", data={"kind": "titre", "page_id": page_id})
resp = client.post(
f"/document/{support}/theme/apply",
json={"theme_id": "securite-incendie", "mode": "template_content"},
)
assert resp.status_code == 200
pages = document_engine.list_document_pages(support)
theme = document_engine.get_document_theme_entry("securite-incendie")
assert theme is not None
assert len(pages) == len(theme["seed_pages"])
def test_document_theme_apply_with_empty_theme_id_removes_the_theme(client: FlaskClient, support: str) -> None:
# Retour utilisateur du 26/09/2026 : la modale "Utiliser un modèle"
# propose une carte "Aucun modèle" pour "revenir à un document de
# base" — theme_id vide est le signal que la route interprète comme
# un retrait, jamais comme une erreur de validation.
page_id = _page_id(support)
client.post(f"/document/{support}/elements/add", data={"kind": "titre", "page_id": page_id})
client.post(
f"/document/{support}/theme/apply",
json={"theme_id": "securite-incendie", "mode": "keep_content"},
)
assert db.get_document_theme(support) == "securite-incendie"
resp = client.post(f"/document/{support}/theme/apply", json={"theme_id": ""})
assert resp.status_code == 200
assert resp.get_json() == {"ok": True, "theme_id": ""}
# Retire vraiment la ligne _meta (voir db/supports/remove_document_theme.py)
# — jamais une chaîne vide qui violerait le contrat documenté de
# get_document_theme ("None" = aucun thème jamais appliqué/retiré).
assert db.get_document_theme(support) is None
# Le contenu n'est JAMAIS touché en retirant le thème.
assert len(document_engine.list_document_elements(support, page_id)) == 1
edit_resp = client.get(f"/document/{support}/edit")
assert "document/themes/securite-incendie.css" not in edit_resp.get_data(as_text=True)
def test_document_theme_apply_with_empty_theme_id_ignores_mode(client: FlaskClient, support: str) -> None:
resp = client.post(f"/document/{support}/theme/apply", json={"theme_id": "", "mode": "n-importe-quoi"})
assert resp.status_code == 200
assert db.get_document_theme(support) is None
def test_document_theme_preview_renders_every_seed_page_with_navigation(client: FlaskClient, support: str) -> None:
resp = client.get(f"/document/{support}/theme/securite-incendie/preview")
assert resp.status_code == 200
html = resp.get_data(as_text=True)
theme = document_engine.get_document_theme_entry("securite-incendie")
assert theme is not None
page_count = len(theme["seed_pages"])
assert html.count('class="docPage"') == page_count
# Une seule page visible au chargement (les autres portent `hidden`,
# navigation gérée côté client — voir templates/document/
# document_theme_preview.html).
assert html.count("hidden>") == page_count - 1
assert "previewNav" in html
def test_document_theme_preview_404s_for_an_unknown_theme(client: FlaskClient, support: str) -> None:
resp = client.get(f"/document/{support}/theme/n-importe-quoi/preview")
assert resp.status_code == 404
+119
View File
@@ -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"
)
+95
View File
@@ -0,0 +1,95 @@
"""Modèle de données du mini-jeu Memory
(document_engine/labels/memory_config.py) — sans Flask, teste directement
sanitize_memory_config, et le rendu du plateau."""
from typing import Any
import document_engine as doc_engine
def test_sanitize_memory_config_on_missing_input_returns_full_defaults() -> None:
assert doc_engine.sanitize_memory_config(None) == {"theme_color": "#ff5f2e", "mode": "paire", "cards": []}
def test_sanitize_memory_config_keeps_a_valid_card() -> None:
raw = {
"theme_color": "#123456",
"mode": "single",
"cards": [{"recto": {"image": "", "text": "?"}, "verso": {"image": "cat.png", "text": "Chat"}}],
}
config = doc_engine.sanitize_memory_config(raw)
assert config["theme_color"] == "#123456"
assert config["mode"] == "single"
assert config["cards"] == [{"recto": {"image": "", "text": "?"}, "verso": {"image": "cat.png", "text": "Chat"}}]
def test_sanitize_memory_config_allows_a_fully_blank_recto() -> None:
raw = {"cards": [{"recto": {}, "verso": {"text": "Chat"}}]}
config = doc_engine.sanitize_memory_config(raw)
assert config["cards"] == [{"recto": {"image": "", "text": ""}, "verso": {"image": "", "text": "Chat"}}]
def test_sanitize_memory_config_drops_a_card_with_a_fully_blank_verso() -> None:
raw = {"cards": [{"recto": {"text": "Indice"}, "verso": {"image": "", "text": " "}}]}
assert doc_engine.sanitize_memory_config(raw)["cards"] == []
def test_sanitize_memory_config_falls_back_to_paire_on_invalid_mode() -> None:
assert doc_engine.sanitize_memory_config({"mode": "n_importe_quoi"})["mode"] == "paire"
def test_sanitize_memory_config_caps_cards_at_max() -> None:
raw = {"cards": [{"verso": {"text": f"Carte {i}"}} for i in range(doc_engine.MAX_CARDS + 3)]}
config = doc_engine.sanitize_memory_config(raw)
assert len(config["cards"]) == doc_engine.MAX_CARDS
def test_sanitize_memory_config_ignores_garbage_top_level_input() -> None:
assert doc_engine.sanitize_memory_config("n'importe quoi") == doc_engine.DEFAULT_MEMORY_CONFIG
def _memory_element(attributes: dict[str, Any]) -> dict[str, Any]:
return {"id": 1, "kind": "memory", "parent_id": None, "order_index": 0, "attributes": attributes}
def test_render_memory_without_cards_shows_only_the_badge() -> None:
html = doc_engine.render_document_element(_memory_element(doc_engine.DEFAULT_MEMORY_CONFIG), {})
assert "0 carte" in html
assert "docMemoryPlayer" not in html
def test_render_memory_with_cards_includes_the_interactive_board() -> None:
config = doc_engine.sanitize_memory_config(
{"mode": "paire", "cards": [{"verso": {"text": "Chat"}}, {"verso": {"text": "Chien"}}]}
)
html = doc_engine.render_document_element(_memory_element(config), {})
assert "docMemoryPlayer" in html
assert "data-memory-config=" in html
assert "2 cartes" in html
assert "mode paire" in html
def test_render_memory_duplicates_cards_in_paire_mode_only() -> None:
import json
config = doc_engine.sanitize_memory_config({"mode": "paire", "cards": [{"verso": {"text": "Chat"}}]})
html_paire = doc_engine.render_document_element(_memory_element(config), {})
start = html_paire.find('data-memory-config="') + len('data-memory-config="')
end = html_paire.find('"', start)
embedded = json.loads(html_paire[start:end].replace("&quot;", '"'))
assert len(embedded["cards"]) == 2
assert embedded["cards"][0]["card_index"] == embedded["cards"][1]["card_index"] == 0
config_single = doc_engine.sanitize_memory_config({"mode": "single", "cards": [{"verso": {"text": "Chat"}}]})
html_single = doc_engine.render_document_element(_memory_element(config_single), {})
start2 = html_single.find('data-memory-config="') + len('data-memory-config="')
end2 = html_single.find('"', start2)
embedded_single = json.loads(html_single[start2:end2].replace("&quot;", '"'))
assert len(embedded_single["cards"]) == 1
def test_render_memory_escapes_card_text_in_embedded_json() -> None:
config = doc_engine.sanitize_memory_config({"cards": [{"verso": {"text": '"><script>alert(1)</script>'}}]})
html = doc_engine.render_document_element(_memory_element(config), {})
assert "<script>alert(1)</script>" not in html
assert "&lt;script&gt;" in html
+122
View File
@@ -0,0 +1,122 @@
"""Modèle de données du mini-jeu Mots mêlés
(document_engine/labels/mots_config.py) — sans Flask, teste directement
sanitize_mots_config, la construction de la grille
(_build_mots_grid, via render_document_element), et le rendu du plateau."""
import json
from typing import Any
import document_engine as doc_engine
from document_engine.rendering.render_document_element import _build_mots_grid
def test_sanitize_mots_config_on_missing_input_returns_full_defaults() -> None:
assert doc_engine.sanitize_mots_config(None) == {"theme_color": "#ff5f2e", "words": []}
def test_sanitize_mots_config_keeps_valid_words_uppercased() -> None:
raw = {"theme_color": "#123456", "words": ["chat", "chien"]}
config = doc_engine.sanitize_mots_config(raw)
assert config["theme_color"] == "#123456"
assert config["words"] == ["CHAT", "CHIEN"]
def test_sanitize_mots_config_strips_accents_and_non_letters() -> None:
raw = {"words": ["éléphant", "un chat !", "12"]}
config = doc_engine.sanitize_mots_config(raw)
assert config["words"] == ["ELEPHANT", "UNCHAT"]
def test_sanitize_mots_config_drops_words_shorter_than_min_length() -> None:
raw = {"words": ["a", "ok"]}
assert doc_engine.sanitize_mots_config(raw)["words"] == ["OK"]
def test_sanitize_mots_config_deduplicates_words() -> None:
raw = {"words": ["chat", "CHAT", "Chat"]}
assert doc_engine.sanitize_mots_config(raw)["words"] == ["CHAT"]
def test_sanitize_mots_config_caps_words_at_max() -> None:
alphabet = "abcdefghijklmnop"
raw = {"words": [f"mot{alphabet[i]}" for i in range(doc_engine.MAX_WORDS + 3)]}
config = doc_engine.sanitize_mots_config(raw)
assert len(config["words"]) == doc_engine.MAX_WORDS
def test_sanitize_mots_config_ignores_garbage_top_level_input() -> None:
assert doc_engine.sanitize_mots_config("n'importe quoi") == doc_engine.DEFAULT_MOTS_CONFIG
def _mots_element(attributes: dict[str, Any]) -> dict[str, Any]:
return {"id": 1, "kind": "mots", "parent_id": None, "order_index": 0, "attributes": attributes}
def test_render_mots_without_words_shows_only_the_badge() -> None:
html = doc_engine.render_document_element(_mots_element(doc_engine.DEFAULT_MOTS_CONFIG), {})
assert "0 mot" in html
assert "docMotsPlayer" not in html
def test_render_mots_with_words_includes_the_interactive_grid() -> None:
config = doc_engine.sanitize_mots_config({"words": ["chat", "chien", "lapin", "oiseau", "poisson"]})
html = doc_engine.render_document_element(_mots_element(config), {})
assert "docMotsPlayer" in html
assert "data-mots-config=" in html
assert "5 mots" in html
def test_render_mots_escapes_word_text_in_embedded_json() -> None:
# Un mot ne peut de toute façon contenir que des lettres A-Z une fois
# sanitizé (voir sanitize_mots_config) : rien à échapper dans le texte
# du mot lui-même. Le seul contenu utilisateur qui atteint réellement
# l'attribut embarqué est donc déjà sûr par construction — ce test
# vérifie que l'échappement JSON standard reste bien en place (guillemets).
config = doc_engine.sanitize_mots_config({"words": ["chat", "chien"]})
html = doc_engine.render_document_element(_mots_element(config), {})
start = html.find('data-mots-config="') + len('data-mots-config="')
end = html.find('"', start)
embedded = json.loads(html[start:end].replace("&quot;", '"'))
assert {"CHAT", "CHIEN"} <= {w["text"] for w in embedded["words"]}
def _cells_spell(grid: list[list[str]], cells: list[list[int]]) -> str:
return "".join(grid[r][c] for r, c in cells)
def test_build_mots_grid_places_every_word_along_its_own_cells() -> None:
words = ["CHAT", "CHIEN", "OISEAU", "POISSON", "LAPIN"]
built = _build_mots_grid(words)
size = built["size"]
grid = built["grid"]
assert len(grid) == size
assert all(len(row) == size for row in grid)
placed = {w["text"] for w in built["words"]}
assert placed == set(words)
for w in built["words"]:
assert _cells_spell(grid, w["cells"]) == w["text"]
def test_build_mots_grid_only_uses_the_four_allowed_directions() -> None:
words = ["CHAT", "CHIEN", "OISEAU", "POISSON", "LAPIN", "SOURIS"]
built = _build_mots_grid(words)
allowed = {(0, 1), (1, 0), (1, 1), (1, -1)}
for w in built["words"]:
cells = w["cells"]
if len(cells) < 2:
continue
delta_row = cells[1][0] - cells[0][0]
delta_col = cells[1][1] - cells[0][1]
assert (delta_row, delta_col) in allowed
for i in range(len(cells) - 1):
assert cells[i + 1][0] - cells[i][0] == delta_row
assert cells[i + 1][1] - cells[i][1] == delta_col
def test_build_mots_grid_fills_every_remaining_cell_with_a_letter() -> None:
built = _build_mots_grid(["CHAT", "CHIEN"])
assert all(cell for row in built["grid"] for cell in row)
def test_build_mots_grid_on_empty_words_returns_an_empty_grid() -> None:
assert _build_mots_grid([]) == {"size": 0, "grid": [], "words": []}
@@ -0,0 +1,71 @@
"""Nettoyage du SVG inline collé comme contenu d'image (voir
document_engine/rendering/sanitize_svg_markup.py) — chaque cas ici
reproduit une charge malveillante RÉELLE plutôt qu'une simple assertion
"pas de régression" (voir CLAUDE.md, exigence pour tout changement de
comportement lié à l'échappement/la sécurité)."""
from document_engine.rendering.sanitize_svg_markup import sanitize_svg_markup
def test_strips_script_tag_and_its_content() -> None:
result = sanitize_svg_markup("<svg><script>alert(document.cookie)</script></svg>")
assert "<script>" not in result
assert "alert(document.cookie)" not in result
def test_strips_event_handler_attributes() -> None:
result = sanitize_svg_markup('<svg onload="alert(1)"><circle onclick="alert(2)" cx="5" cy="5" r="3"/></svg>')
assert "onload" not in result
assert "onclick" not in result
assert "alert(" not in result
def test_strips_href_to_block_javascript_uri() -> None:
result = sanitize_svg_markup('<svg><a href="javascript:alert(1)"><circle cx="1" cy="1" r="1"/></a></svg>')
assert "javascript:" not in result
assert "<a" not in result
assert "href" not in result
def test_strips_foreignobject_and_embedded_html() -> None:
result = sanitize_svg_markup(
'<svg><foreignObject><body onload="alert(1)"><img src="x" onerror="alert(2)"></body></foreignObject></svg>'
)
assert "foreignObject".lower() not in result.lower()
assert "onerror" not in result
assert "alert(" not in result
def test_strips_style_attribute_and_style_tag() -> None:
result = sanitize_svg_markup(
'<svg><style>*{display:none}</style><circle style="fill:red" cx="1" cy="1" r="1"/></svg>'
)
assert "<style>" not in result
assert "style=" not in result
assert "display:none" not in result
def test_strips_use_tag_referencing_external_content() -> None:
result = sanitize_svg_markup('<svg><use href="https://evil.test/x.svg#payload"/></svg>')
assert "<use" not in result
assert "evil.test" not in result
def test_keeps_allowed_shape_and_presentation_attributes() -> None:
result = sanitize_svg_markup(
'<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" fill="#ff0000" stroke="#000"/></svg>'
)
assert "<svg" in result
assert "<circle" in result
assert 'cx="12"' in result
assert 'fill="#ff0000"' in result
assert 'stroke="#000"' in result
def test_self_closing_disallowed_tag_does_not_swallow_following_content() -> None:
result = sanitize_svg_markup('<svg><script/><circle cx="1" cy="1" r="1"/></svg>')
assert "<circle" in result
def test_empty_markup_returns_empty_string() -> None:
assert sanitize_svg_markup("") == ""
+310
View File
@@ -0,0 +1,310 @@
"""Modèle de données du mini-jeu Scénario
(document_engine/labels/scenario_config.py) — sans Flask, teste
directement sanitize_scenario_config (arbre de décision, graphe visuel
avec positions x/y), et le rendu du plateau."""
from typing import Any
import document_engine as doc_engine
def test_sanitize_scenario_config_on_missing_input_returns_full_defaults() -> None:
assert doc_engine.sanitize_scenario_config(None) == {"theme_color": "#ff5f2e", "scenarios": []}
def test_sanitize_scenario_config_keeps_a_valid_tree_as_is() -> None:
raw = {
"theme_color": "#123456",
"scenarios": [
{
"title": "Mot de passe",
"nodes": [
{
"id": "n1",
"text": "Un collègue vous demande son mot de passe.",
"x": 40,
"y": 60,
"choices": [
{"text": "Le lui donner", "target_id": "n2"},
{"text": "Refuser", "target_id": None},
],
},
{"id": "n2", "text": "Violation de sécurité.", "x": 300, "y": 60, "choices": []},
],
}
],
}
config = doc_engine.sanitize_scenario_config(raw)
assert config["theme_color"] == "#123456"
assert config["scenarios"] == raw["scenarios"]
def test_sanitize_scenario_config_keeps_explicit_xy_position() -> None:
raw = {
"scenarios": [
{"title": "Titre", "nodes": [{"id": "n1", "text": "Situation", "x": 500, "y": 300, "choices": []}]}
]
}
config = doc_engine.sanitize_scenario_config(raw)
node = config["scenarios"][0]["nodes"][0]
assert (node["x"], node["y"]) == (500, 300)
def test_sanitize_scenario_config_falls_back_to_a_cascading_grid_when_xy_is_missing() -> None:
"""Jamais (0, 0) pour tous les nœuds (qui les empilerait exactement au
même endroit sur le graphe visuel) : une position par défaut dérivée
de l'INDEX du nœud dans la liste."""
raw = {
"scenarios": [
{
"title": "Titre",
"nodes": [
{"id": "n1", "text": "A", "choices": []},
{"id": "n2", "text": "B", "choices": []},
],
}
]
}
config = doc_engine.sanitize_scenario_config(raw)
nodes = config["scenarios"][0]["nodes"]
assert (nodes[0]["x"], nodes[0]["y"]) != (nodes[1]["x"], nodes[1]["y"])
def test_sanitize_scenario_config_falls_back_to_grid_when_xy_is_not_a_number() -> None:
raw = {
"scenarios": [
{"title": "Titre", "nodes": [{"id": "n1", "text": "Situation", "x": "beaucoup", "y": None, "choices": []}]}
]
}
config = doc_engine.sanitize_scenario_config(raw)
node = config["scenarios"][0]["nodes"][0]
assert isinstance(node["x"], (int, float))
assert isinstance(node["y"], (int, float))
def test_sanitize_scenario_config_rejects_a_boolean_as_xy() -> None:
"""True/False sont des int en Python (bool hérite de int) — même
garde-fou que pour correct_index dans l'ancien modèle : un JSON
malformé pourrait glisser `true` là où une coordonnée est attendue."""
raw = {
"scenarios": [
{"title": "Titre", "nodes": [{"id": "n1", "text": "Situation", "x": True, "y": False, "choices": []}]}
]
}
config = doc_engine.sanitize_scenario_config(raw)
node = config["scenarios"][0]["nodes"][0]
assert node["x"] is not True
assert node["y"] is not False
def test_sanitize_scenario_config_drops_a_scenario_with_empty_title() -> None:
raw = {"scenarios": [{"title": " ", "nodes": [{"id": "n1", "text": "Situation", "choices": []}]}]}
assert doc_engine.sanitize_scenario_config(raw)["scenarios"] == []
def test_sanitize_scenario_config_drops_a_scenario_with_no_valid_node() -> None:
raw = {"scenarios": [{"title": "Titre", "nodes": [{"id": "n1", "text": " ", "choices": []}]}]}
assert doc_engine.sanitize_scenario_config(raw)["scenarios"] == []
def test_sanitize_scenario_config_drops_a_node_missing_its_id() -> None:
raw = {
"scenarios": [
{
"title": "Titre",
"nodes": [
{"id": "n1", "text": "Situation", "choices": []},
{"text": "Nœud sans id", "choices": []},
],
}
]
}
config = doc_engine.sanitize_scenario_config(raw)
assert len(config["scenarios"][0]["nodes"]) == 1
def test_sanitize_scenario_config_drops_a_node_with_empty_text() -> None:
raw = {
"scenarios": [
{
"title": "Titre",
"nodes": [
{"id": "n1", "text": "Situation", "choices": []},
{"id": "n2", "text": " ", "choices": []},
],
}
]
}
config = doc_engine.sanitize_scenario_config(raw)
assert [n["id"] for n in config["scenarios"][0]["nodes"]] == ["n1"]
def test_sanitize_scenario_config_drops_duplicate_node_ids() -> None:
raw = {
"scenarios": [
{
"title": "Titre",
"nodes": [
{"id": "n1", "text": "Situation", "choices": []},
{"id": "n1", "text": "Doublon", "choices": []},
],
}
]
}
config = doc_engine.sanitize_scenario_config(raw)
assert len(config["scenarios"][0]["nodes"]) == 1
assert config["scenarios"][0]["nodes"][0]["text"] == "Situation"
def test_sanitize_scenario_config_drops_a_choice_with_empty_text() -> None:
raw = {
"scenarios": [
{
"title": "Titre",
"nodes": [
{
"id": "n1",
"text": "Situation",
"choices": [{"text": " ", "target_id": None}, {"text": "Valide", "target_id": None}],
}
],
}
]
}
config = doc_engine.sanitize_scenario_config(raw)
assert [c["text"] for c in config["scenarios"][0]["nodes"][0]["choices"]] == ["Valide"]
def test_sanitize_scenario_config_caps_choices_per_node_at_max() -> None:
raw = {
"scenarios": [
{
"title": "Titre",
"nodes": [
{
"id": "n1",
"text": "Situation",
"choices": [
{"text": f"Choix {i}", "target_id": None}
for i in range(doc_engine.MAX_SCENARIO_CHOICES + 3)
],
}
],
}
]
}
config = doc_engine.sanitize_scenario_config(raw)
assert len(config["scenarios"][0]["nodes"][0]["choices"]) == doc_engine.MAX_SCENARIO_CHOICES
def test_sanitize_scenario_config_allows_a_node_with_zero_choices() -> None:
"""Un nœud sans choix est une fin de branche VALIDE — contrairement à
l'ancien modèle (2 à 4 choix obligatoires), il n'y a plus de notion
de bonne/mauvaise réponse à valider ici."""
raw = {"scenarios": [{"title": "Titre", "nodes": [{"id": "n1", "text": "Fin.", "choices": []}]}]}
config = doc_engine.sanitize_scenario_config(raw)
assert config["scenarios"][0]["nodes"][0]["choices"] == []
def test_sanitize_scenario_config_nulls_a_target_id_pointing_to_a_missing_node() -> None:
raw = {
"scenarios": [
{
"title": "Titre",
"nodes": [
{
"id": "n1",
"text": "Situation",
"choices": [{"text": "Vers un nœud supprimé", "target_id": "n99"}],
}
],
}
]
}
config = doc_engine.sanitize_scenario_config(raw)
assert config["scenarios"][0]["nodes"][0]["choices"][0]["target_id"] is None
def test_sanitize_scenario_config_keeps_a_target_id_pointing_to_a_later_node() -> None:
"""L'ordre des nœuds dans la liste ne contraint pas les références —
un choix du nœud 0 peut viser un nœud défini plus loin dans la liste
(voir le second passage de _sanitize_scenario, après avoir construit
l'ensemble complet des ids valides)."""
raw = {
"scenarios": [
{
"title": "Titre",
"nodes": [
{"id": "n1", "text": "Situation", "choices": [{"text": "Continuer", "target_id": "n2"}]},
{"id": "n2", "text": "Suite", "choices": []},
],
}
]
}
config = doc_engine.sanitize_scenario_config(raw)
assert config["scenarios"][0]["nodes"][0]["choices"][0]["target_id"] == "n2"
def test_sanitize_scenario_config_keeps_multiple_scenarios_in_order() -> None:
raw = {
"scenarios": [
{"title": "Scénario 1", "nodes": [{"id": "n1", "text": "Situation 1", "choices": []}]},
{"title": "Scénario 2", "nodes": [{"id": "n1", "text": "Situation 2", "choices": []}]},
]
}
config = doc_engine.sanitize_scenario_config(raw)
assert [s["title"] for s in config["scenarios"]] == ["Scénario 1", "Scénario 2"]
def test_sanitize_scenario_config_ignores_garbage_top_level_input() -> None:
assert doc_engine.sanitize_scenario_config("n'importe quoi") == doc_engine.DEFAULT_SCENARIO_CONFIG
def _scenario_element(attributes: dict[str, Any]) -> dict[str, Any]:
return {"id": 1, "kind": "scenario", "parent_id": None, "order_index": 0, "attributes": attributes}
def test_render_scenario_without_scenarios_shows_only_the_badge() -> None:
html = doc_engine.render_document_element(_scenario_element(doc_engine.DEFAULT_SCENARIO_CONFIG), {})
assert "0 scénario" in html
assert "docScenarioPlayer" not in html
def test_render_scenario_with_scenarios_includes_the_interactive_player() -> None:
config = doc_engine.sanitize_scenario_config(
{
"scenarios": [
{
"title": "Mot de passe",
"nodes": [
{
"id": "n1",
"text": "Un collègue vous demande son mot de passe.",
"choices": [{"text": "Refuser", "target_id": None}],
}
],
}
]
}
)
html = doc_engine.render_document_element(_scenario_element(config), {})
assert "docScenarioPlayer" in html
assert "docQuizOptions" in html
assert "data-scenario-config=" in html
assert "1 scénario" in html
def test_render_scenario_escapes_node_text_in_embedded_json() -> None:
config = doc_engine.sanitize_scenario_config(
{
"scenarios": [
{
"title": "Titre",
"nodes": [{"id": "n1", "text": '"><script>alert(1)</script>', "choices": []}],
}
]
}
)
html = doc_engine.render_document_element(_scenario_element(config), {})
assert "<script>alert(1)</script>" not in html
assert "&lt;script&gt;" in html
+55
View File
@@ -16,6 +16,16 @@ def test_create_support_creates_its_own_db_file_and_schema(tmp_support_slug_clea
assert meta["name"] == "Sécurité incendie" assert meta["name"] == "Sécurité incendie"
def test_new_support_has_no_page_by_default(tmp_support_slug_cleanup: Any) -> None:
# Retour utilisateur du 26/09/2026 : "l'éditeur ne dois plus etre
# obliger d'avoir une page active" — voir document_engine/pages/pages.md.
import document_engine
slug = db.create_support("Nouveau projet", owner_folder="52")
tmp_support_slug_cleanup(slug)
assert document_engine.list_document_pages(slug) == []
def test_list_supports_scopes_to_owner_and_excludes_games( def test_list_supports_scopes_to_owner_and_excludes_games(
tmp_support_slug_cleanup: Any, tmp_game_slug_cleanup: Any tmp_support_slug_cleanup: Any, tmp_game_slug_cleanup: Any
) -> None: ) -> None:
@@ -59,3 +69,48 @@ def test_delete_support_removes_it_from_the_listing(tmp_support_slug_cleanup: An
assert [s["slug"] for s in db.list_supports("48")] == [slug] assert [s["slug"] for s in db.list_supports("48")] == [slug]
db.delete_support(slug) db.delete_support(slug)
assert db.list_supports("48") == [] assert db.list_supports("48") == []
def test_new_support_has_no_theme_by_default(tmp_support_slug_cleanup: Any) -> None:
slug = db.create_support("Sans thème", owner_folder="49")
tmp_support_slug_cleanup(slug)
assert db.get_document_theme(slug) is None
assert db.support_meta(slug)["theme"] is None
def test_set_document_theme_persists_and_is_readable_back(tmp_support_slug_cleanup: Any) -> None:
slug = db.create_support("Avec thème", owner_folder="50")
tmp_support_slug_cleanup(slug)
db.set_document_theme(slug, "securite-incendie")
assert db.get_document_theme(slug) == "securite-incendie"
assert db.support_meta(slug)["theme"] == "securite-incendie"
def test_set_document_theme_can_be_changed(tmp_support_slug_cleanup: Any) -> None:
slug = db.create_support("Change de thème", owner_folder="51")
tmp_support_slug_cleanup(slug)
db.set_document_theme(slug, "securite-incendie")
db.set_document_theme(slug, "autre-theme")
assert db.get_document_theme(slug) == "autre-theme"
def test_remove_document_theme_resets_to_none(tmp_support_slug_cleanup: Any) -> None:
# Retour utilisateur du 26/09/2026 : la modale "Utiliser un modèle"
# propose une carte "Aucun modèle" pour "revenir à un document de
# base" — get_document_theme doit redevenir None, jamais une chaîne
# vide (contrat documenté dans db/supports/get_document_theme.py).
slug = db.create_support("Retire son thème", owner_folder="54")
tmp_support_slug_cleanup(slug)
db.set_document_theme(slug, "securite-incendie")
assert db.get_document_theme(slug) == "securite-incendie"
db.remove_document_theme(slug)
assert db.get_document_theme(slug) is None
assert db.support_meta(slug)["theme"] is None
def test_remove_document_theme_is_a_noop_when_none_was_ever_set(tmp_support_slug_cleanup: Any) -> None:
slug = db.create_support("Jamais de thème", owner_folder="55")
tmp_support_slug_cleanup(slug)
db.remove_document_theme(slug)
assert db.get_document_theme(slug) is None
+12
View File
@@ -26,3 +26,15 @@ from core import (
) )
_ = (auth_guard, csrf, csrf_guard, db_teardown_guard, jinja_filters, recovery_codes_flash) _ = (auth_guard, csrf, csrf_guard, db_teardown_guard, jinja_filters, recovery_codes_flash)
# routes/document/document_theme_preview.py::document_theme_preview(slug, theme_id) —
# `slug` doit rester dans la signature (Flask appelle la vue avec un
# kwarg par segment <slug>/<theme_id> de la route, TypeError sinon), mais
# le corps de la fonction ne s'en sert pas : l'aperçu d'un thème ne
# dépend d'aucune donnée DU support, `slug` ne sert qu'à laisser
# core/auth_guard.py (générique sur `request.view_args.get("slug")`)
# vérifier la propriété avant d'atteindre la vue.
def _unused_but_required_route_param(slug: str) -> None:
_ = slug