Compare commits

...
38 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
58 changed files with 7182 additions and 214 deletions
+1 -1
View File
@@ -152,7 +152,7 @@ SonarQube : voir section 2, sous-section "SonarQube" — CI restaurée (non-bloq
| `static/game/js/play/offline/filter-repeater-rows.js:24` (`forgeDecodeClauses`) | `javascript:S2486` | **Corrigé, même patron que ci-dessus** : `_filtres_json` est un attribut rendu par le serveur, jamais tapé à la main — un JSON invalide y trahit presque toujours un bug côté éditeur/serveur. `console.warn('_filtres_json invalide, filtre ignoré', e)` ajouté, comportement inchangé (repli sur l'ancien format à 2 filtres fixes ou aucun filtre). Couvert par un nouveau test (`filter-repeater-rows.test.js`, `forgeDecodeClauses — _filtres_json invalide`). | Lot 4 "modernisation JS", 18/09/2026 |
| `static/game/js/play/offline/filter-repeater-rows.js:59`, `:70` (`forgeResolveVariablePath`, JSON.parse + navigation `.champ`/`[index]`) | `javascript:S2486` | **Documenté, pas corrigé — nature différente du cas ci-dessus** : ici `rawValue` est la VALEUR ACTUELLE d'une variable de jeu (modifiable librement par n'importe quelle action "Modifier une variable"), pas une config interne à l'éditeur — un chemin qui ne correspond pas à sa forme actuelle est un cas normal et attendu (ex. variable encore à sa valeur par défaut non-JSON), déjà explicitement documenté par le commentaire de la fonction ("Ne lève jamais... même convention que côté serveur"). Un `console.warn` ici bruiterait la console à chaque usage légitime. | Lot 4 "modernisation JS", 18/09/2026 |
| `static/document/js/document-editor.js` (`FORGE_DOC_STYLE_PRESETS`, `FORGE_DOC_SHAPE_KINDS`, `FORGE_DOC_SNAP_SIZE` — 3 sites) | `eslint:no-var`, `eslint:vars-on-top` | Constantes de premier niveau déclarées en `var` plutôt que `const` : un `<script src>` de page est rejoué TEL QUEL par `pjax.js` (`runScriptsIn`) à chaque navigation interne — une redéclaration `let`/`const` au premier niveau lèverait `SyntaxError: already declared` à la 2e exécution (voir l'en-tête de `static/pjax.js`, et le commentaire d'en-tête de ce fichier). `var` est le seul mot-clé sûr à ce niveau ; tout le reste du fichier (état mutable, y compris à l'intérieur des fonctions) est bien en `let`/`const`, porté par `window.forgeDocState` plutôt que par des variables de module (même convention que `static/game/js/scenes/scene-editor.js` et les autres scripts de page existants, qui n'ont eux aucune constante de ce genre à déclarer). | Session du 20/09/2026 |
| `templates/document/document_edit.html` (`.docPageRow`, rangées de l'onglet "Pages" du panneau gauche) + `static/document/js/document-editor.js` (`forgeDocRenderPageManagerList`) | `Web:S6819`, `Web:MouseEventWithoutKeyboardEquivalentCheck` (Sonar) | `div role="button" tabindex="0"` volontaire : chaque rangée contient de vrais `<button>` d'action (monter/descendre/renommer/supprimer, voir `.docPageRowActions`), qu'un `<button>` englobant ne pourrait pas contenir validement (imbrication de `<button>` invalide, le parseur HTML referme le bouton englobant trop tôt — même défaut structurel déjà rencontré et corrigé de la même façon ailleurs dans ce fichier). L'équivalent clavier (Entrée/Espace déclenche `forgeDocSwitchPage`, même effet que le clic) est posé côté JS (`row.addEventListener('keydown', ...)`), donc le finding clavier de Sonar est un faux positif : l'analyseur statique ne voit pas les `addEventListener` attachés dynamiquement. Vérifié par un test jsdom dédié (rôle `button`, équivalent clavier fonctionnel). | Session du 21/09/2026 ; renommé (panneau à onglets) le 23/09/2026 |
| `templates/document/document_edit.html` (`.docPageRow`, rangées de l'onglet "Pages" du panneau gauche) + `static/document/js/document-editor.js` (`forgeDocRenderPageManagerList`) | `Web:S6819`, `Web:MouseEventWithoutKeyboardEquivalentCheck` (Sonar) | `div role="button" tabindex="0"` volontaire : chaque rangée contient de vrais `<button>` d'action (renommer/supprimer, voir `.docPageRowActions` — le réordonnancement, ancien 3ᵉ/4ᵉ bouton monter/descendre, est passé au glisser-déposer seul le 23/09/2026, retour utilisateur : "je pouvais changer l'ordre des pages en glisser déposer donc les flèches [...] sont inutiles"), qu'un `<button>` englobant ne pourrait pas contenir validement (imbrication de `<button>` invalide, le parseur HTML referme le bouton englobant trop tôt — même défaut structurel déjà rencontré et corrigé de la même façon ailleurs dans ce fichier). L'équivalent clavier (Entrée/Espace déclenche `forgeDocSwitchPage`, même effet que le clic) est posé côté JS (`row.addEventListener('keydown', ...)`), donc le finding clavier de Sonar est un faux positif : l'analyseur statique ne voit pas les `addEventListener` attachés dynamiquement. Vérifié par un test jsdom dédié (rôle `button`, équivalent clavier fonctionnel). | Session du 21/09/2026 ; renommé (panneau à onglets) le 23/09/2026 ; boutons monter/descendre retirés le 23/09/2026 |
### Détail — `javascript:S8786` (ReDoS), lot 1 "modernisation JS"
+8
View File
@@ -78,7 +78,11 @@ from .scoring.set_status import set_status
from .slugify import slugify
from .supports.create_support import create_support
from .supports.delete_support import delete_support
from .supports.get_document_theme import get_document_theme
from .supports.list_supports import list_supports
from .supports.remove_document_theme import remove_document_theme
from .supports.set_document_theme import set_document_theme
from .supports.support_dir import support_dir
from .supports.support_meta import support_meta
from .table_name_for import table_name_for
@@ -115,8 +119,12 @@ __all__ = [
"set_scorm_version",
"create_support",
"list_supports",
"support_dir",
"support_meta",
"delete_support",
"get_document_theme",
"remove_document_theme",
"set_document_theme",
"ONBOARDING_TYPES",
"DEFAULT_ONBOARDING_TYPE",
"get_onboarding_type",
+6
View File
@@ -7,7 +7,10 @@ indépendance."""
from .create_support import create_support
from .delete_support import delete_support
from .ensure_document_pages_schema import ensure_document_pages_schema
from .get_document_theme import get_document_theme
from .list_supports import list_supports
from .remove_document_theme import remove_document_theme
from .set_document_theme import set_document_theme
from .support_connection import connect_support, install_support_teardown_safety_net
from .support_dir import support_dir
from .support_meta import support_meta
@@ -20,8 +23,11 @@ __all__ = [
"create_support",
"delete_support",
"ensure_document_pages_schema",
"get_document_theme",
"install_support_teardown_safety_net",
"list_supports",
"remove_document_theme",
"set_document_theme",
"split_slug",
"support_dir",
"support_meta",
+10 -7
View File
@@ -13,12 +13,15 @@ def create_support(name: str, owner_folder: str) -> str:
mais un schéma entièrement différent (voir document_engine/ : un
support est composé de PAGES — _document_pages —, chacune portant son
propre flux d'éléments via _document_elements.page_id, jamais
d'écrans/objets de scène comme côté jeu). Une première page ("Page 1")
est toujours créée ici : un support n'existe jamais sans au moins une
page (voir document_engine/pages/delete_document_page.py, qui refuse
de supprimer la dernière). owner_folder n'est jamais optionnel ici
(contrairement à create_game) : un support n'existe pas sans compte
propriétaire."""
d'écrans/objets de scène comme côté jeu). Créé ici SANS aucune page
(retour utilisateur : l'éditeur doit pouvoir s'ouvrir vide, "nouveau
projet par ex") — l'utilisateur clique "+ Ajouter une page" pour
commencer (voir document_engine/pages/add_document_page.py). Aucune
page n'est donc plus une garantie côté production ; seule la fixture
de test `support` (tests/conftest.py) en crée une par convénience pour
les tests qui ne portent pas sur ce cas précis. owner_folder n'est
jamais optionnel ici (contrairement à create_game) : un support
n'existe pas sans compte propriétaire."""
project_part = slugify(name)
base = project_part
n = 2
@@ -39,6 +42,7 @@ def create_support(name: str, owner_folder: str) -> str:
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL DEFAULT 'Page 1',
order_index INTEGER NOT NULL DEFAULT 0,
vertical_align TEXT NOT NULL DEFAULT 'top',
created_at TEXT DEFAULT CURRENT_TIMESTAMP
);
@@ -54,7 +58,6 @@ def create_support(name: str, owner_folder: str) -> str:
"""
)
conn.execute("INSERT INTO _meta (key, value) VALUES ('name', ?)", (name,))
conn.execute("INSERT INTO _document_pages (title, order_index) VALUES ('Page 1', 0)")
conn.commit()
conn.close()
return slug
+26 -5
View File
@@ -8,7 +8,15 @@ def ensure_document_pages_schema(slug: str) -> None:
le système de pages avait un seul document implicite ; ses éléments
existants deviennent tous les enfants d'une page "Page 1" créée ici
automatiquement (comportement le plus proche de l'ancien : un seul
document visible, qui devient simplement sa première page)."""
document visible, qui devient simplement sa première page).
Ne recrée PLUS "Page 1" à chaque appel dès que _document_pages est
vide (bug qui empêchait tout support d'atteindre 0 page — un support
sans aucune page est un état valide depuis le retour utilisateur
"l'éditeur doit pouvoir s'ouvrir sans aucune page"). La création
automatique de "Page 1" ne se déclenche plus qu'une seule fois, au
moment précis de cette migration historique (juste avant d'ajouter la
colonne page_id, voir plus bas) — jamais ensuite."""
conn = connect_support(slug)
conn.execute(
"""
@@ -20,13 +28,26 @@ def ensure_document_pages_schema(slug: str) -> None:
);
"""
)
page_count = conn.execute("SELECT COUNT(*) AS n FROM _document_pages").fetchone()["n"]
if page_count == 0:
conn.execute("INSERT INTO _document_pages (title, order_index) VALUES ('Page 1', 0)")
# vertical_align : alignement vertical du contenu de la page
# (top/center/bottom — voir document_engine/pages/
# set_document_page_vertical_align.py), ajouté après la création
# initiale des pages — même pragmatisme que page_id ci-dessous, une
# valeur par défaut CONSTANTE plutôt qu'une contrainte CHECK.
page_cols = {r["name"] for r in conn.execute("PRAGMA table_info(_document_pages)").fetchall()}
if "vertical_align" not in page_cols:
conn.execute("ALTER TABLE _document_pages ADD COLUMN vertical_align TEXT NOT NULL DEFAULT 'top'")
cols = {r["name"] for r in conn.execute("PRAGMA table_info(_document_elements)").fetchall()}
if "page_id" not in cols:
first_page_id = conn.execute("SELECT id FROM _document_pages ORDER BY order_index LIMIT 1").fetchone()["id"]
# Vrai support pré-pages : ses éléments existants (s'il y en a)
# doivent atterrir quelque part — jamais recréé une fois cette
# migration ponctuelle passée (page_id existera alors déjà).
page_row = conn.execute("SELECT id FROM _document_pages ORDER BY order_index LIMIT 1").fetchone()
if page_row is None:
conn.execute("INSERT INTO _document_pages (title, order_index) VALUES ('Page 1', 0)")
page_row = conn.execute("SELECT id FROM _document_pages ORDER BY order_index LIMIT 1").fetchone()
first_page_id = page_row["id"]
# SQLite autorise ADD COLUMN avec une valeur par défaut CONSTANTE
# (jamais une contrainte REFERENCES ici, même pragmatisme que
# db/rows/ensure_player_id_column.py : la contrainte de clé
+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 .get_document_theme import get_document_theme
from .support_connection import connect_support
@@ -10,4 +11,5 @@ def support_meta(slug: str) -> dict[str, Any]:
return {
"slug": slug,
"name": row["value"] if row else slug,
"theme": get_document_theme(slug),
}
+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.
+16
View File
@@ -27,6 +27,7 @@ from .elements.delete_document_element import delete_document_element
from .elements.get_document_element import get_document_element
from .elements.list_document_elements import list_document_elements
from .elements.move_document_element import move_document_element
from .elements.move_document_element_to_page import move_document_element_to_page
from .elements.update_document_element_attributes import update_document_element_attributes
from .labels.association_config import (
DEFAULT_ASSOCIATION_CONFIG,
@@ -70,12 +71,18 @@ from .labels.scenario_config import (
sanitize_scenario_config,
)
from .pages.add_document_page import add_document_page
from .pages.delete_all_document_pages import delete_all_document_pages
from .pages.delete_document_page import delete_document_page
from .pages.get_document_page import get_document_page
from .pages.list_document_pages import list_document_pages
from .pages.move_document_page import move_document_page
from .pages.replace_document_content import replace_document_content
from .pages.set_document_page_vertical_align import VERTICAL_ALIGNS, set_document_page_vertical_align
from .pages.update_document_page import update_document_page
from .rendering.render_document_element import render_document, render_document_element
from .rendering.sanitize_svg_markup import sanitize_svg_markup
from .themes.seed_blocks_to_elements import seed_blocks_to_elements
from .themes.theme_catalog import DOCUMENT_THEMES, get_document_theme_entry
__all__ = [
"CARD_MODES",
@@ -85,6 +92,7 @@ __all__ = [
"DEFAULT_MOTS_CONFIG",
"DEFAULT_QUIZ_CONFIG",
"DEFAULT_SCENARIO_CONFIG",
"DOCUMENT_THEMES",
"ELEMENT_KIND_LABELS",
"ELEMENT_LIBRARY",
"MAX_CARDS",
@@ -99,26 +107,34 @@ __all__ = [
"MIN_TIMER_SECONDS",
"MIN_WORDS",
"MINIGAME_KINDS",
"VERTICAL_ALIGNS",
"add_document_element",
"add_document_page",
"delete_all_document_pages",
"delete_document_element",
"delete_document_page",
"element_default_attributes",
"get_document_element",
"get_document_page",
"get_document_theme_entry",
"list_document_elements",
"list_document_pages",
"move_document_element",
"move_document_element_to_page",
"move_document_page",
"quiz_total_points",
"render_document",
"render_document_element",
"replace_document_content",
"sanitize_association_config",
"sanitize_element_attributes",
"sanitize_memory_config",
"sanitize_mots_config",
"sanitize_quiz_config",
"sanitize_scenario_config",
"sanitize_svg_markup",
"seed_blocks_to_elements",
"set_document_page_vertical_align",
"update_document_element_attributes",
"update_document_page",
]
+5 -1
View File
@@ -1,15 +1,19 @@
# document_engine/
Moteur du support de formation — entité racine séparée du jeu 2D (voir
`docs/plan/PLAN.md`). Package composé de trois sous-dossiers, chacun
`docs/plan/PLAN.md`). Package composé de cinq sous-dossiers, chacun
documenté séparément :
- [`elements/`](elements/elements.md) — CRUD des éléments du document
(`_document_elements`).
- [`labels/`](labels/labels.md) — catalogue statique des types d'éléments
(bibliothèque, libellés, attributs par défaut).
- [`pages/`](pages/pages.md) — CRUD des pages d'un support
(`_document_pages`) et remplacement complet du contenu depuis un thème.
- [`rendering/`](rendering/rendering.md) — rendu HTML du document (canevas
d'édition et Mode Aperçu, même fonction).
- [`themes/`](themes/themes.md) — catalogue des thèmes visuels
applicables à un support (bouton "Utiliser un modèle").
`document_engine/__init__.py` ré-exporte l'intégralité de l'API publique
du paquet (voir son `__all__`), pattern identique à `game_engine/__init__.py`.
+18
View File
@@ -57,6 +57,24 @@ affichée).
inexistant ne fait rien (retour silencieux après vérification de son
existence).
## `move_document_element_to_page(slug: str, element_id: int, target_page_id: int) -> None`
Déplace un élément vers une AUTRE page du même support — utilisé par la
pagination automatique (retour utilisateur du 23/09/2026 : quand le
contenu déborde d'une page, l'élément en trop est déplacé vers une
nouvelle page plutôt que d'y rester tassé). L'élément redevient TOUJOURS
top-niveau sur la page cible (`parent_id` remis à `NULL`) — une rangée
qui existait sur l'ancienne page n'a aucun sens comme enfant d'une
rangée de la page cible. Si l'élément déplacé est lui-même une rangée,
ses enfants directs (même `parent_id`) SUIVENT sur la page cible
(`page_id` mis à jour en cascade, `parent_id` inchangé) — sans cette
cascade ils resteraient orphelins d'une page qu'ils n'occupent plus
(`list_document_elements`, filtré par `page_id`, ne les retrouverait
plus). Renumérote les anciens frères après le retrait.
- **Retour** : aucun.
- **Exceptions** : aucune levée explicitement ; un `element_id`
inexistant ne fait rien (retour silencieux après vérification de son
existence).
## `delete_document_element(slug: str, element_id: int) -> None`
Supprime un élément. La contrainte `FOREIGN KEY ... ON DELETE CASCADE`
(voir `db/supports/create_support.py`) retire automatiquement ses
@@ -0,0 +1,47 @@
from db.supports import connect_support, ensure_document_pages_schema
def move_document_element_to_page(slug: str, element_id: int, target_page_id: int) -> None:
"""Déplace un élément vers une AUTRE page du même support — utilisé
par la pagination automatique (retour utilisateur du 23/09/2026 :
"si il n'y a plus de place sur la page il faut automatiquement créer
une autre page [et y déplacer] le contenu", voir
static/document/js/document-editor.js, forgeDocCheckPageOverflow).
L'élément redevient TOUJOURS top-niveau sur la page cible (parent_id
NULL) — une rangée qui existait sur l'ancienne page n'a aucun sens
comme enfant d'une rangée de la page cible. Si l'élément déplacé est
lui-même une rangée, ses enfants directs (même parent_id) SUIVENT sur
la page cible (cascade sur page_id, parent_id inchangé) : sans cette
cascade, list_document_elements (filtré par page_id) ne les
retrouverait plus, alors qu'ils resteraient en base rattachés à une
rangée désormais sur une autre page — état incohérent silencieux."""
ensure_document_pages_schema(slug)
conn = connect_support(slug)
row = conn.execute("SELECT page_id, parent_id FROM _document_elements WHERE id = ?", (element_id,)).fetchone()
if not row:
conn.close()
return
old_page_id = row["page_id"]
old_parent_id = row["parent_id"]
old_siblings = [
r["id"]
for r in conn.execute(
"SELECT id FROM _document_elements WHERE page_id = ? AND parent_id IS ? AND id != ? ORDER BY order_index",
(old_page_id, old_parent_id, element_id),
).fetchall()
]
for index, sibling_id in enumerate(old_siblings):
conn.execute("UPDATE _document_elements SET order_index = ? WHERE id = ?", (index, sibling_id))
new_top_level_count = conn.execute(
"SELECT COUNT(*) AS n FROM _document_elements WHERE page_id = ? AND parent_id IS NULL", (target_page_id,)
).fetchone()["n"]
conn.execute(
"UPDATE _document_elements SET page_id = ?, parent_id = NULL, order_index = ? WHERE id = ?",
(target_page_id, new_top_level_count, element_id),
)
conn.execute("UPDATE _document_elements SET page_id = ? WHERE parent_id = ?", (target_page_id, element_id))
conn.commit()
conn.close()
+171 -5
View File
@@ -5,13 +5,14 @@ défaut posés à la création de chaque type."""
from typing import Any
from ..rendering.box_style import BOX_DEFAULTS, default_border
from .association_config import DEFAULT_ASSOCIATION_CONFIG
from .memory_config import DEFAULT_MEMORY_CONFIG
from .mots_config import DEFAULT_MOTS_CONFIG
from .quiz_config import DEFAULT_QUIZ_CONFIG
from .scenario_config import DEFAULT_SCENARIO_CONFIG
CONTENT_KINDS = ("titre", "paragraphe", "image", "bouton")
CONTENT_KINDS = ("titre", "paragraphe", "image", "bouton", "liste_puces", "liste_numerotee", "badge", "carte")
MINIGAME_KINDS = ("quiz", "association", "memory", "mots", "scenario", "zones")
# "row" n'apparaît jamais dans la bibliothèque (créé implicitement par le
@@ -28,6 +29,10 @@ ELEMENT_KIND_LABELS: dict[str, str] = {
"paragraphe": "Paragraphe",
"image": "Image",
"bouton": "Bouton",
"liste_puces": "Liste à puces",
"liste_numerotee": "Liste numérotée",
"badge": "Étiquette",
"carte": "Carte",
"quiz": "Quiz",
"association": "Association",
"memory": "Memory",
@@ -40,8 +45,24 @@ _TEXT_DEFAULTS = {
"bold": False,
"italic": False,
"underline": False,
"strikethrough": False,
"align": "left",
"color": "var(--forge-text)",
# Vides par défaut = valeurs du préréglage `style` inchangées (voir
# _STYLE_PRESETS dans render_document_element.py) — une valeur
# explicite les remplace (retour utilisateur du 26/09/2026, audit des
# réglages manquants).
"font_family": "",
"font_size": "",
"line_height": "",
"letter_spacing": "",
"text_transform": "none",
"text_shadow": "",
# Attributs de boîte partagés avec d'autres kinds (dont `max_width`,
# ex. "60ch"/"480px" — retour utilisateur du 24/09/2026 : un
# paragraphe doit pouvoir rester plus étroit que la page) — voir
# rendering/box_style.py.
**BOX_DEFAULTS,
}
@@ -50,13 +71,158 @@ def element_default_attributes(kind: str) -> dict[str, Any]:
docs/plan/PLAN.md §3.3/§3.4/§3.5 pour la liste des propriétés
éditables par panneau, ici juste leur valeur de départ."""
if kind == "titre":
return {"content": "Nouveau titre", "style": "titre1", **_TEXT_DEFAULTS}
# "border" copié à chaque appel (jamais un dict partagé/muté par
# référence entre plusieurs éléments — même raison que la copie
# de "questions" pour le quiz plus bas).
return {"content": "Nouveau titre", "style": "titre1", **_TEXT_DEFAULTS, "border": default_border()}
if kind == "paragraphe":
return {"content": "Nouveau paragraphe de texte.", "style": "paragraphe", **_TEXT_DEFAULTS}
return {
"content": "Nouveau paragraphe de texte.",
"style": "paragraphe",
**_TEXT_DEFAULTS,
"border": default_border(),
}
if kind == "image":
return {"src": "", "alt": ""}
# svg_markup (optionnel) prend le pas sur src au rendu (voir
# render_document_element._render_image) — un contenu vectoriel
# dessiné/collé directement plutôt qu'un fichier hébergé.
# aspect_ratio/filter_preset : vides par défaut (aucun style
# ajouté). click_behavior/link_url/lazy_load/caption :
# comportement/contenu, pas du style (retour utilisateur du
# 26/09/2026, audit des réglages manquants — colonne "Image").
# click_behavior ("" | "link" | "lightbox") et link_url sont
# mutuellement dépendants (un lien sans URL ne fait rien au
# rendu, voir _render_image) mais jamais revalidés l'un par
# rapport à l'autre ici : cette combinaison reste sans risque
# quelle qu'elle soit.
#
# object_fit="cover" + height="220px" (au lieu de vides) : cadre
# FIXE par défaut (retour utilisateur du 26/09/2026 : "ce cadre
# ne devrait pas changer de taille en fonction de la taille de
# l'image mais être fixe et contraindre l'image dedans") — une
# photo importée est désormais TOUJOURS rognée pour remplir ce
# cadre, quelle que soit sa résolution native, plutôt que de
# dicter elle-même la taille du bloc. "Taille réelle" reste
# sélectionnable explicitement dans le panneau Propriétés
# (segmented "Ajustement dans son cadre") pour qui préfère
# revenir à l'ancien comportement (hauteur libre, aucun
# object-fit) ; un défaut CSS aveugle sur TOUTE image aurait
# rendu ce choix impossible à distinguer de "jamais réglé", les
# deux valant la chaîne vide.
return {
"src": "",
"alt": "",
"svg_markup": "",
"object_fit": "cover",
"aspect_ratio": "",
"filter_preset": "",
"click_behavior": "",
"link_url": "",
"lazy_load": False,
"caption": "",
**BOX_DEFAULTS,
"height": "220px",
"border": default_border(),
}
if kind == "bouton":
return {"label": "Bouton", "target": ""}
# attachment_stored_name/attachment_filename (optionnels) : un
# fichier téléchargeable joint au bouton (voir routes/document/
# document_element_upload_attachment.py), indépendant de `target`
# qui reste réservé à la navigation (URL/ancre). Jamais les deux
# à la fois côté UI (voir document-editor.js), mais rien ne
# l'empêche structurellement ici.
#
# Audit du 26/09/2026 (réglages manquants — colonne "Bouton") :
# bold/italic/text_transform/font_family/font_size/letter_spacing/
# text_color (typo, jamais gérés par box_style.py) + svg_markup/
# icon_position/icon_size (icône avant/après le texte, absente
# même de l'étiquette qui n'a qu'un SVG fixe sans position ni
# taille réglables) + les attributs de boîte partagés. bold=False
# PAR DÉFAUT (le CSS de base garde son font-weight:700 tel quel
# tant que "bold" n'est pas explicitement activé — voir
# _render_button, qui ne pousse à 800 QUE si bold=True — aucune
# régression visuelle sur les boutons déjà créés).
return {
"label": "Bouton",
"target": "",
"attachment_stored_name": "",
"attachment_filename": "",
"bold": False,
"italic": False,
"text_transform": "none",
"font_family": "",
"font_size": "",
"letter_spacing": "",
"text_color": "",
"svg_markup": "",
"icon_position": "before",
"icon_size": "",
**BOX_DEFAULTS,
"border": default_border(),
}
if kind in ("liste_puces", "liste_numerotee"):
# Une seule et même structure d'attributs pour les deux kinds —
# "ordonnée ou non" se lit directement sur le kind au moment du
# rendu (voir render_document_element._render_list), jamais un
# attribut "ordered" redondant à tenir synchronisé avec le kind.
#
# Audit du 26/09/2026 (réglages manquants — colonne "Liste à
# puces/numérotée") : portée actée avec l'utilisateur = style de
# la LISTE ENTIÈRE (typo/puces/boîte/bordure/fond), jamais un
# style par élément individuel ni de sous-listes (chantier bien
# plus lourd, transformerait `items` d'une liste de chaînes en
# objets structurés — différé à une demande séparée). Seule
# exception : `item_padding`, un padding UNIFORME appliqué à
# CHAQUE élément (retour utilisateur explicite : "il faut un
# padding de base par élément de liste car y en a pas
# aujourd'hui") — une valeur PARTAGÉE par tous les éléments,
# jamais réglable individuellement, voir _render_list.
# list_style_type : valide uniquement parmi les valeurs propres
# au kind (disc/circle/square/none pour puces,
# decimal/.../none pour numérotée) — vérifié au rendu, jamais ici.
# svg_markup (puce personnalisée) : ignoré au rendu pour
# liste_numerotee (une puce imagée n'a pas de sens sur une liste
# numérotée, voir _render_list).
return {
"items": ["Premier élément", "Deuxième élément"],
"bold": False,
"italic": False,
"underline": False,
"font_family": "",
"font_size": "",
"line_height": "",
"text_color": "",
"list_style_type": "",
"list_style_position": "outside",
"marker_color": "",
"marker_size": "",
"svg_markup": "",
"item_padding": "6px",
"item_spacing": "",
**BOX_DEFAULTS,
"border": default_border(),
}
if kind == "badge":
# Même esprit que titre/paragraphe (bold/align/color... déjà des
# attributs par élément, pas des choix figés par le moteur) :
# svg_markup/width/border_radius/bold/uppercase vides ou False par
# défaut = comportement historique inchangé (pleine largeur, sans
# icône, casse normale) ; un thème ou le créateur les règle au cas
# par cas (retour utilisateur du 24/09/2026).
return {
"content": "Étiquette",
"svg_markup": "",
"width": "",
"border_radius": "",
"bold": False,
"uppercase": False,
}
if kind == "carte":
# Contenu pur (lettre/repère court + titre + description) — sans
# aucun choix de couleur/forme, laissé au futur système de
# templates (voir consigne du 24/09/2026 : moteur = contenu et
# mécanisme uniquement, jamais de style).
return {"label": "A", "title": "Titre de la carte", "description": "Description de la carte."}
if kind == "row":
return {"gap": 16, "align": "stretch", "justify": "flex-start"}
if kind == "quiz":
+69 -9
View File
@@ -5,8 +5,24 @@ groupée par catégorie (panneau gauche de l'éditeur), libellés d'affichage, e
attributs par défaut posés à la création de chaque type.
## `CONTENT_KINDS: tuple[str, ...]`
`("titre", "paragraphe", "image", "bouton")` — éléments du flux, peuvent
être top-niveau ou enfants d'une rangée.
`("titre", "paragraphe", "image", "bouton", "liste_puces",
"liste_numerotee", "badge", "carte")` — éléments du flux, peuvent être
top-niveau ou enfants d'une rangée. `"liste_puces"`/`"liste_numerotee"`
partagent la même structure d'attributs (`items`) ; c'est le `kind`
lui-même qui décide `<ul>` ou `<ol>` au rendu (voir
`document_engine/rendering/render_document_element.py::_render_list`),
jamais un attribut `ordered` redondant. `"carte"` reste du contenu PUR,
sans aucun attribut de style/couleur/forme (décision du 24/09/2026 : le
moteur ne porte que contenu et mécanisme, tout habillage visuel revient
à un futur système de templates). `"badge"` a depuis gagné des
attributs de mise en forme PAR ÉLÉMENT (`svg_markup`/`width`/
`border_radius`/`bold`/`uppercase`, retour utilisateur du 24/09/2026)
— même esprit que `bold`/`align`/`color` déjà présents sur
`"titre"`/`"paragraphe"` : des réglages posés par le créateur ou un
thème au cas par cas, jamais une valeur figée dans le moteur. Voir
aussi le mode SVG inline de `"image"` (`svg_markup`) et la pièce
jointe téléchargeable de `"bouton"` (`attachment_filename`),
ci-dessous.
## `MINIGAME_KINDS: tuple[str, ...]`
`("quiz", "association", "memory", "mots", "scenario", "zones")` —
@@ -28,13 +44,57 @@ l'affichage/debug hors bibliothèque).
## `element_default_attributes(kind: str) -> dict[str, Any]`
Attributs posés à la création d'un élément de ce type (voir
`document_engine/elements/add_document_element.py`).
- **Retour** : un dict d'attributs par défaut, dépendant du `kind` :
texte (`content/style` + `bold/italic/underline/align/color`),
image (`src/alt`), bouton (`label/target`), rangée (`gap/align/justify`),
quiz (`DEFAULT_QUIZ_CONFIG`, voir `quiz_config.py`), association
(`DEFAULT_ASSOCIATION_CONFIG`, voir `association_config.py`), memory
(`DEFAULT_MEMORY_CONFIG`, voir `memory_config.py`), autre mini-jeu
(`theme_color`), ou `{}` pour un `kind` inconnu.
- **Retour** : un dict d'attributs par défaut, dépendant du `kind` (les
"attributs de boîte partagés" mentionnés ci-dessous — `padding/margin/
background_color/border_radius/width/max_width/height/min_height/
max_height/min_width/box_shadow/opacity/align_self/content_align/
border` — sont toujours les mêmes, voir `rendering/box_style.py` :
tous vides, `False` ou `"none"`/`"stretch"`/`"top"` par défaut =
comportement historique inchangé pour le kind qui les gagne ;
`content_align` — alignement vertical du CONTENU dans son bloc, retour
utilisateur du 26/09/2026 — n'est cependant appliqué au rendu QUE par
les kinds dont le conteneur est en colonne (texte, liste, figure d'une
image légendée), jamais par le Bouton, voir `rendering.md`) :
texte (`content/style` + `bold/italic/underline/strikethrough/align/
color/font_family/font_size/line_height/letter_spacing/
text_transform/text_shadow` + les attributs de boîte partagés),
image (`src/alt/svg_markup` — `svg_markup` prend le pas sur `src` au
rendu, voir rendering.md — `object_fit="cover"` et `height="220px"`
par défaut (retour utilisateur du 26/09/2026 : "ce cadre ne devrait
pas changer de taille en fonction de la taille de l'image mais être
fixe et contraindre l'image dedans" — un cadre FIXE, jamais dicté par
la résolution native du fichier importé ; "Taille réelle" reste un
choix explicite possible via le panneau), `aspect_ratio/filter_preset`
vides par défaut, `click_behavior` (`""`/`"link"`/`"lightbox"`) +
`link_url` + `lazy_load` + `caption` : comportement/contenu, pas du
style + les attributs de boîte partagés), bouton (`label/target/
attachment_stored_name/attachment_filename` — la pièce jointe est
indépendante de `target`, réservé à la navigation — + `bold/italic/
text_transform/font_family/font_size/letter_spacing/text_color`
(typo, jamais gérés par box_style.py) + `svg_markup/icon_position
("before"/"after")/icon_size` (icône optionnelle, voir rendering.md)
+ les attributs de boîte partagés), liste à
puces/numérotée (`items`, une liste de chaînes — style sur la LISTE
ENTIÈRE uniquement, jamais par élément individuel ni de sous-listes,
portée actée avec l'utilisateur le 26/09/2026 : `bold/italic/
underline/font_family/font_size/line_height/text_color` (typo) +
`list_style_type` (valide selon le kind, voir rendering.md)/
`list_style_position`/`marker_color`/`marker_size`/`svg_markup`
(puce personnalisée, liste à puces uniquement) + `item_padding`
(`"6px"` par défaut, PAS vide — retour utilisateur explicite : "il
faut un padding de base par élément de liste car y en a pas
aujourd'hui", UNIFORME sur tous les éléments, jamais réglable
individuellement) + `item_spacing` + les attributs de boîte
partagés), badge
(`content/svg_markup/width/border_radius/bold/uppercase` — tous vides
ou `False` par défaut = comportement historique inchangé, voir
rendering.md), carte (`label/title/description`, contenu pur sans
couleur/forme),
rangée (`gap/align/justify`), quiz (`DEFAULT_QUIZ_CONFIG`, voir
`quiz_config.py`), association (`DEFAULT_ASSOCIATION_CONFIG`, voir
`association_config.py`), memory (`DEFAULT_MEMORY_CONFIG`, voir
`memory_config.py`), autre mini-jeu (`theme_color`), ou `{}` pour un
`kind` inconnu.
- **Exceptions** : aucune.
## `quiz_config.py` — modèle de données du mini-jeu Quiz
@@ -0,0 +1,16 @@
from db.supports import connect_support, ensure_document_pages_schema
def delete_all_document_pages(slug: str) -> None:
"""Supprime toutes les pages d'un support d'un coup (retour
utilisateur : "une option dans page pour supprimer toute les page
d'un coup") — CASCADE (contrainte FK, voir create_support.py) retire
aussi tous les éléments de contenu du support. Résultat : un support
à 0 page, état volontairement valide (voir list_document_pages.py) ;
l'utilisateur repart d'un éditeur vide comme un support neuf."""
ensure_document_pages_schema(slug)
conn = connect_support(slug)
conn.execute("DELETE FROM _document_elements")
conn.execute("DELETE FROM _document_pages")
conn.commit()
conn.close()
+3 -2
View File
@@ -7,8 +7,9 @@ def list_document_pages(slug: str) -> list[dict[str, Any]]:
"""Toutes les pages d'un support, triées par order_index — la bande
d'onglets du panneau Propriétés (voir static/document/js/
document-editor.js) et le sélecteur de page du Mode Aperçu en dérivent
directement. Un support a toujours au moins une page (voir
db/supports/create_support.py / ensure_document_pages_schema)."""
directement. Peut renvoyer une liste VIDE (retour utilisateur : un
support neuf, ou vidé via "Supprimer toutes les pages", s'ouvre sans
aucune page — voir routes/document/document_edit.py, qui gère ce cas)."""
ensure_document_pages_schema(slug)
conn = connect_support(slug)
rows = conn.execute("SELECT * FROM _document_pages ORDER BY order_index").fetchall()
+67 -11
View File
@@ -4,13 +4,21 @@ CRUD des pages d'un support de formation (`_document_pages`, voir
`db/supports/create_support.py`) — retour utilisateur du 21/09/2026:
"il faut implémenter un système de page". Un support est désormais
composé de plusieurs pages, chacune portant son propre flux d'éléments
(voir `document_engine/elements/`, filtré par `page_id`). Un support a
TOUJOURS au moins une page (`create_support` en crée une par défaut,
`ensure_document_pages_schema` en garantit une pour les supports plus
anciens) — la garde "jamais supprimer la dernière page" est un
garde-fou métier posé par l'appelant (voir
`routes/document/document_page_delete.py`), pas une contrainte de ce
paquet.
(voir `document_engine/elements/`, filtré par `page_id`).
**Un support peut avoir 0 page** (retour utilisateur du 26/09/2026 :
"l'éditeur ne dois plus etre obliger d'avoir une page active ou créer,
il peut etre ouvert sans aucune page") — `create_support` n'en crée plus
aucune par défaut, et `ensure_document_pages_schema` ne recrée plus
"Page 1" dès que la table est vide (seule exception : la migration
ponctuelle et historique d'un support pré-pages qui avait déjà des
éléments sans `page_id`). `routes/document/document_edit.py` et le
frontend (`static/document/js/document-editor.js`) gèrent explicitement
cet état "aucune page" (pas de page active, canevas vide avec une
invite à en créer une). La garde "jamais supprimer la dernière page" a
été retirée du côté route (voir `delete_all_document_pages` ci-dessous
et `routes/document/document_page_delete.py`) : ce paquet n'a jamais
posé cette contrainte lui-même.
## `add_document_page(slug: str, title: str | None = None) -> int`
Ajoute une page en fin de la bande d'onglets. `title` par défaut :
@@ -43,14 +51,62 @@ décalage un par un).
- **Retour** : aucun.
- **Exceptions** : aucune.
## `set_document_page_vertical_align(slug: str, page_id: int, vertical_align: str) -> None`
Règle l'alignement vertical du CONTENU d'une page (`justify-content` de
`.docPageContent`, voir `static/document/document-editor.css`) —
réglable depuis le panneau Propriétés quand l'onglet "Pages" de
l'éditeur est actif (retour utilisateur du 24/09/2026 : "quand je suis
sur l'onglet page, dans les propriétés s'affiche l'option de
l'alignement de la page"). Une valeur hors de `VERTICAL_ALIGNS`
(`"top"`/`"center"`/`"bottom"`) retombe silencieusement sur `"top"`,
même philosophie défensive que `update_document_page` pour un titre
vide.
- **Retour** : aucun.
- **Exceptions** : aucune.
### `VERTICAL_ALIGNS: tuple[str, ...]`
`("top", "center", "bottom")` — valeurs valides de `vertical_align`,
`"top"` étant la valeur par défaut posée en base (voir
`db/supports/create_support.py`/`ensure_document_pages_schema.py`).
## `replace_document_content(slug: str, seed_pages: list[dict[str, Any]]) -> None`
Remplace TOUT le contenu du support par `seed_pages` — utilisée
UNIQUEMENT quand le créateur choisit "utiliser le contenu du modèle" en
appliquant un thème (voir `routes/document/document_theme_apply.py` et
`document_engine/themes/`), jamais appelée sans confirmation explicite
côté client (action destructive, irréversible côté serveur). `seed_pages`
est une liste de pages, chaque page un dict
`{"vertical_align": "top"|"center"|"bottom", "blocks": [...]}`
(`vertical_align` optionnel, retombe sur `"top"`) ; chaque bloc de
`blocks` est `{"kind", "attributes", "children"}` (`children` optionnel,
uniquement pour un bloc `kind="row"` — un seul niveau de profondeur,
comme le moteur de rangées lui-même). Les attributs fournis sont
fusionnés sur `element_default_attributes(kind)`, jamais un remplacement
brut. Les nouvelles pages sont créées AVANT que les anciennes soient
supprimées (jamais l'inverse) : passer par zéro page, même brièvement,
déclenche le filet de sécurité de `ensure_document_pages_schema` (un
support a toujours au moins une page), qui recréerait une "Page 1" vide
parasite.
- **Retour** : aucun.
- **Exceptions** : aucune levée explicitement.
## `delete_document_page(slug: str, page_id: int) -> None`
Supprime une page ET ses éléments (`DELETE FROM _document_elements
WHERE page_id = ?` explicite — la contrainte `FOREIGN KEY ... ON DELETE
CASCADE` n'existe que pour les supports créés après l'ajout des pages,
voir `db/supports/ensure_document_pages_schema.py` pour les anciens).
Ne refuse JAMAIS de supprimer la dernière page restante — cette règle
est posée par l'appelant, pas par cette fonction bas niveau (même
découpage que `routes/game/screens/screen_delete.py` côté jeu, où le
garde-fou vit aussi dans la route).
Ne refuse JAMAIS de supprimer la dernière page restante — un support à
0 page est un état valide (voir plus haut).
- **Retour** : aucun.
- **Exceptions** : aucune.
## `delete_all_document_pages(slug: str) -> None`
Supprime TOUTES les pages du support d'un coup, et tous leurs éléments
de contenu avec elles (retour utilisateur : "une option dans page pour
supprimer toute les page d'un coup") — action destructive et
irréversible côté serveur, jamais appelée sans confirmation explicite
côté client (voir `static/document/js/document-editor.js`,
`forgeDocDeleteAllPages`). Le support se retrouve à 0 page, exactement
comme un support neuf.
- **Retour** : aucun.
- **Exceptions** : aucune.
@@ -0,0 +1,54 @@
from typing import Any
from ..elements.add_document_element import add_document_element
from ..elements.update_document_element_attributes import update_document_element_attributes
from ..labels.element_kind_labels import element_default_attributes
from .add_document_page import add_document_page
from .delete_document_page import delete_document_page
from .list_document_pages import list_document_pages
from .set_document_page_vertical_align import set_document_page_vertical_align
def replace_document_content(slug: str, seed_pages: list[dict[str, Any]]) -> None:
"""Remplace TOUT le contenu du support par `seed_pages` — utilisé
UNIQUEMENT quand le créateur choisit explicitement "utiliser le
contenu du modèle" en appliquant un thème (voir routes/document/
document_theme_apply.py, jamais appelée sans confirmation explicite
côté client : action destructive, irréversible côté serveur).
`seed_pages` est une liste de pages, chaque page un dict
`{"vertical_align": "top"|"center"|"bottom", "blocks": [...]}`
(`vertical_align` optionnel, retombe sur "top" — voir
set_document_page_vertical_align.VERTICAL_ALIGNS) ; chaque bloc de
`blocks` est `{"kind": str, "attributes": dict, "children": [...]}`
(`children` optionnel, uniquement pour un bloc `kind="row"` — chaque
enfant a la même forme `{"kind", "attributes"}`, sans petit-enfant :
le moteur de rangées ne descend jamais à plus d'un niveau, voir
document_engine/rendering/render_document_element.py::_render_row).
Les attributs fournis sont FUSIONNÉS sur
element_default_attributes(kind) (jamais un remplacement brut) pour
rester valides même si `seed_pages` n'en précise qu'une partie."""
# Les nouvelles pages sont créées AVANT de supprimer les anciennes
# (jamais l'inverse) : ça évite que le support affiche un état "0 page"
# transitoire pendant le remplacement (même si 0 page est désormais un
# état par ailleurs valide, voir list_document_pages.py — ce n'est
# qu'une question d'ordre d'écriture ici, plus un filet de sécurité).
old_page_ids = [page["id"] for page in list_document_pages(slug)]
for seed_page in seed_pages:
page_id = add_document_page(slug)
vertical_align = seed_page.get("vertical_align")
if vertical_align:
set_document_page_vertical_align(slug, page_id, vertical_align)
for block in seed_page.get("blocks", []):
_add_seed_block(slug, page_id, block, parent_id=None)
for old_page_id in old_page_ids:
delete_document_page(slug, old_page_id)
def _add_seed_block(slug: str, page_id: int, block: dict[str, Any], parent_id: int | None) -> None:
kind = block["kind"]
element_id = add_document_element(slug, kind, page_id=page_id, parent_id=parent_id)
attributes = {**element_default_attributes(kind), **block.get("attributes", {})}
update_document_element_attributes(slug, element_id, attributes)
for child in block.get("children", []):
_add_seed_block(slug, page_id, child, parent_id=element_id)
@@ -0,0 +1,20 @@
from db.supports import connect_support, ensure_document_pages_schema
VERTICAL_ALIGNS = ("top", "center", "bottom")
def set_document_page_vertical_align(slug: str, page_id: int, vertical_align: str) -> None:
"""Règle l'alignement vertical du CONTENU d'une page (`justify-content`
de `.docPageContent`, voir static/document/document-editor.css) —
réglable depuis le panneau Propriétés quand l'onglet "Pages" est actif
(retour utilisateur du 24/09/2026), jamais un attribut par élément (ça
concerne la page entière, pas un bloc de contenu particulier). Une
valeur hors de `VERTICAL_ALIGNS` retombe silencieusement sur "top"
(état par défaut) plutôt que de lever — même philosophie défensive que
`update_document_page` pour un titre vide."""
ensure_document_pages_schema(slug)
clean_align = vertical_align if vertical_align in VERTICAL_ALIGNS else "top"
conn = connect_support(slug)
conn.execute("UPDATE _document_pages SET vertical_align = ? WHERE id = ?", (clean_align, page_id))
conn.commit()
conn.close()
+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,8 +1,12 @@
import html as html_lib
import json
import random
import urllib.parse
from typing import Any
from .box_style import render_box_style, render_content_align
from .sanitize_svg_markup import sanitize_svg_markup
def render_document(elements: list[dict[str, Any]]) -> str:
"""Assemble le document ENTIER à partir de la liste à plat renvoyée
@@ -49,29 +53,337 @@ def _render_text(el: dict[str, Any], _children_by_parent: dict[int | None, list[
a = el["attributes"]
content = html_lib.escape(str(a.get("content", "")))
style_name = a.get("style", "paragraphe")
font_size, base_weight, line_height = _STYLE_PRESETS.get(style_name, _STYLE_PRESETS["paragraphe"])
preset_font_size, base_weight, preset_line_height = _STYLE_PRESETS.get(style_name, _STYLE_PRESETS["paragraphe"])
weight = 800 if a.get("bold") else base_weight
font_style = "italic" if a.get("italic") else "normal"
text_decoration = "underline" if a.get("underline") else "none"
# underline/strikethrough se combinent (text-decoration-line accepte
# plusieurs valeurs) — retour utilisateur du 26/09/2026 : "barré"
# manquait à côté du souligné déjà existant.
decoration_parts = []
if a.get("underline"):
decoration_parts.append("underline")
if a.get("strikethrough"):
decoration_parts.append("line-through")
text_decoration = " ".join(decoration_parts) if decoration_parts else "none"
align = html_lib.escape(str(a.get("align", "left")))
color = html_lib.escape(str(a.get("color", "var(--forge-text)")))
# font_size/line_height : vides par défaut = valeurs du préréglage
# `style` (titre1/titre2/paragraphe/légende) inchangées ; une valeur
# explicite les remplace SANS changer `weight` (qui reste piloté par
# le préréglage + `bold`).
font_size = html_lib.escape(str(a.get("font_size", "")).strip()) or preset_font_size
line_height = html_lib.escape(str(a.get("line_height", "")).strip()) or str(preset_line_height)
style = (
f"font-size:{font_size}; font-weight:{weight}; line-height:{line_height}; "
f"font-style:{font_style}; text-decoration:{text_decoration}; text-align:{align}; color:{color};"
)
text_transform = str(a.get("text_transform", "none"))
if text_transform and text_transform != "none":
style += f" text-transform:{html_lib.escape(text_transform)};"
font_family = str(a.get("font_family", "")).strip()
if font_family:
style += f" font-family:{html_lib.escape(font_family)};"
letter_spacing = str(a.get("letter_spacing", "")).strip()
if letter_spacing:
style += f" letter-spacing:{html_lib.escape(letter_spacing)};"
text_shadow = str(a.get("text_shadow", "")).strip()
if text_shadow:
style += f" text-shadow:{html_lib.escape(text_shadow)};"
# max_width (ex. "60ch", "480px" — retour utilisateur du 24/09/2026 :
# un paragraphe doit pouvoir rester plus étroit que la page, sans
# dépendre d'une rangée qui en partagerait la largeur avec un frère)
# fait maintenant partie des attributs de boîte partagés
# (render_box_style), jamais géré ici en double.
box_style = render_box_style(a)
if box_style:
style += f" {box_style}"
content_align = render_content_align(a)
if content_align:
style += f" {content_align}"
return f'<div class="docText" data-element-id="{el["id"]}" data-kind="{el["kind"]}" style="{style}">{content}</div>'
_IMAGE_OBJECT_FITS = ("cover", "contain", "fill")
_IMAGE_FILTERS = {
"grayscale": "grayscale(1)",
"sepia": "sepia(0.8)",
"blur": "blur(3px)",
}
def _image_extra_style(a: dict[str, Any]) -> str:
"""Déclarations CSS spécifiques à l'image (`object-fit`/`aspect-ratio`/
`filter`) — jamais dans `box_style.py` (partagé), qui ne connaît que
des attributs communs à plusieurs kinds."""
parts = []
object_fit = str(a.get("object_fit", ""))
if object_fit in _IMAGE_OBJECT_FITS:
parts.append(f"object-fit:{object_fit};")
aspect_ratio = str(a.get("aspect_ratio", "")).strip()
if aspect_ratio:
parts.append(f"aspect-ratio:{html_lib.escape(aspect_ratio)};")
filter_value = _IMAGE_FILTERS.get(str(a.get("filter_preset", "")))
if filter_value:
parts.append(f"filter:{filter_value};")
return " ".join(parts)
def _render_image(el: dict[str, Any], _children_by_parent: dict[int | None, list[dict[str, Any]]]) -> str:
a = el["attributes"]
click_behavior = str(a.get("click_behavior", ""))
link_url = str(a.get("link_url", "")).strip()
caption = str(a.get("caption", "")).strip()
# render_box_style (padding/margin/fond/bordure/largeur/position du
# bloc, dont align-self) doit se poser sur l'élément RÉELLEMENT
# top-niveau — celui qui est l'enfant direct du flex-column de la
# page (voir .docPageContent, static/document/document-editor.css) —
# jamais sur l'<img>/<div> interne dès qu'une légende ou un
# comportement au clic l'enveloppe : un align-self posé sur un
# DESCENDANT du flex-item n'a strictement aucun effet côté CSS (bug
# réel constaté le 26/09/2026 : "la position de bloc ne fonctionne
# pas sur l'image"). has_wrapper détermine qui, de l'image elle-même
# ou de son enveloppe, est ce top-niveau.
has_wrapper = bool(caption) or (click_behavior == "link" and link_url) or click_behavior == "lightbox"
box_style = render_box_style(a)
media_style = " ".join(p for p in (_image_extra_style(a), "" if has_wrapper else box_style) if p)
media_style_attr = f' style="{media_style}"' if media_style else ""
loading_attr = ' loading="lazy"' if a.get("lazy_load") else ""
svg_markup = str(a.get("svg_markup", "")).strip()
if svg_markup:
# Contenu vectoriel dessiné/collé par le créateur plutôt qu'un
# fichier hébergé — prioritaire sur `src` (voir
# element_kind_labels.element_default_attributes). Nettoyé à
# CHAQUE rendu (jamais seulement à l'écriture) par sanitize_svg_markup,
# même défense en profondeur que html.escape sur les autres kinds.
sanitized = sanitize_svg_markup(svg_markup)
media = (
f'<div class="docImage" data-element-id="{el["id"]}" data-kind="image"{media_style_attr}>{sanitized}</div>'
)
else:
src = html_lib.escape(str(a.get("src", "")))
alt = html_lib.escape(str(a.get("alt", "")))
if not src:
return (
f'<div class="docImage docImagePlaceholder" data-element-id="{el["id"]}" data-kind="image">'
f"Image — aucun fichier choisi</div>"
media = (
f'<div class="docImage docImagePlaceholder" data-element-id="{el["id"]}" '
f'data-kind="image"{media_style_attr}>Image — aucun fichier choisi</div>'
)
return f'<img class="docImage" data-element-id="{el["id"]}" data-kind="image" src="{src}" alt="{alt}">'
else:
media = (
f'<img class="docImage" data-element-id="{el["id"]}" data-kind="image" '
f'src="{src}" alt="{alt}"{media_style_attr}{loading_attr}>'
)
# Comportement au clic (mutuellement exclusif, voir panneau
# Propriétés) — "lien" ouvre une URL externe dans un nouvel onglet
# (jamais dans l'éditeur lui-même), "plein écran" ouvre un aperçu
# agrandi géré côté client (voir static/document/js/
# document-editor.js::forgeDocOpenImageLightbox), tous deux
# UNIQUEMENT actifs en Mode Aperçu (même principe que les mini-jeux
# et la pièce jointe d'un bouton). Reçoit le style de bloc UNIQUEMENT
# s'il n'y a pas de légende par-dessus (sinon c'est elle, plus
# englobante encore, qui le reçoit juste plus bas).
if click_behavior == "link" and link_url:
href = html_lib.escape(link_url)
wrapper_style_attr = f' style="{box_style}"' if (box_style and not caption) else ""
media = (
f'<a class="docImageLink" href="{href}" target="_blank" '
f'rel="noopener noreferrer"{wrapper_style_attr}>{media}</a>'
)
elif click_behavior == "lightbox":
wrapper_style_attr = f' style="{box_style}"' if (box_style and not caption) else ""
media = f'<div class="docImageLightboxTrigger"{wrapper_style_attr}>{media}</div>'
if caption:
# render_content_align (retour utilisateur du 26/09/2026 :
# "je peux augmenter la hauteur d'un conteneur mais pas
# l'alignement vertical à l'intérieur") n'a de sens ici QUE pour
# la figure (conteneur flex-colonne à plusieurs enfants réels —
# image + légende) : jamais sur l'<img> seul ni sur les
# enveloppes lien/plein écran, qui ne sont pas des conteneurs
# flex-colonne à plusieurs enfants.
figure_style = " ".join(p for p in (box_style, render_content_align(a)) if p)
figure_style_attr = f' style="{figure_style}"' if figure_style else ""
media = (
f'<figure class="docImageFigure"{figure_style_attr}>{media}'
f'<figcaption class="docImageCaption">{html_lib.escape(caption)}</figcaption></figure>'
)
return media
_LIST_STYLE_TYPES = {
"liste_puces": ("disc", "circle", "square", "none"),
"liste_numerotee": (
"decimal",
"decimal-leading-zero",
"lower-roman",
"upper-roman",
"lower-alpha",
"upper-alpha",
"none",
),
}
def _list_text_style(a: dict[str, Any]) -> list[str]:
"""Typographie de la liste ENTIÈRE (jamais par élément individuel,
voir element_kind_labels.py — portée actée avec l'utilisateur)."""
parts = []
if a.get("bold"):
parts.append("font-weight:700;")
if a.get("italic"):
parts.append("font-style:italic;")
if a.get("underline"):
parts.append("text-decoration:underline;")
font_family = str(a.get("font_family", "")).strip()
if font_family:
parts.append(f"font-family:{html_lib.escape(font_family)};")
font_size = str(a.get("font_size", "")).strip()
if font_size:
parts.append(f"font-size:{html_lib.escape(font_size)};")
line_height = str(a.get("line_height", "")).strip()
if line_height:
parts.append(f"line-height:{html_lib.escape(line_height)};")
text_color = str(a.get("text_color", "")).strip()
if text_color:
parts.append(f"color:{html_lib.escape(text_color)};")
return parts
def _list_marker_style(a: dict[str, Any], kind: str) -> list[str]:
"""Puces/numéros — `marker_color`/`marker_size` passent par des
PROPRIÉTÉS PERSONNALISÉES CSS (héritées jusqu'au pseudo-élément
`::marker` de chaque <li>, voir .docList li::marker dans
document-editor.css) : un style inline posé sur le <ul>/<ol> ne peut
pas cibler directement le `::marker` de ses enfants autrement."""
parts = []
list_style_type = str(a.get("list_style_type", ""))
if list_style_type in _LIST_STYLE_TYPES.get(kind, ()):
parts.append(f"list-style-type:{list_style_type};")
position_inside = str(a.get("list_style_position", "outside")) == "inside"
if position_inside:
parts.append("list-style-position:inside;")
# Retour utilisateur du 26/09/2026 : "si j'enlève les puces ou que les
# puces se mettent à l'intérieur, il reste un espace devant la liste,
# cet espace doit être supprimé" — le padding-left:1.4em par défaut
# (document-editor.css, .docList) réserve la place d'une puce
# EXTÉRIEURE ; il n'a plus lieu d'être dès que la puce n'est plus là
# ("none") ou qu'elle rejoint le flux du texte ("inside").
if list_style_type == "none" or position_inside:
parts.append("padding-left:0;")
marker_color = str(a.get("marker_color", "")).strip()
if marker_color:
parts.append(f"--doc-marker-color:{html_lib.escape(marker_color)};")
marker_size = str(a.get("marker_size", "")).strip()
if marker_size:
parts.append(f"--doc-marker-size:{html_lib.escape(marker_size)};")
# Puce personnalisée (image SVG) : liste à puces UNIQUEMENT, une
# puce imagée n'a pas de sens sur une liste numérotée. list-style-image
# prime visuellement sur list-style-type dès qu'il est posé (aucun
# conflit à gérer entre les deux).
svg_markup = str(a.get("svg_markup", "")).strip() if kind == "liste_puces" else ""
if svg_markup:
sanitized = sanitize_svg_markup(svg_markup)
encoded = urllib.parse.quote(sanitized)
parts.append(f'list-style-image:url("data:image/svg+xml,{encoded}");')
return parts
def _list_item_style(a: dict[str, Any]) -> list[str]:
"""`item_padding`/`item_spacing` s'appliquent à CHAQUE <li>, jamais au
conteneur <ul>/<ol> lui-même — même mécanisme de propriété
personnalisée CSS héritée que `_list_marker_style` ci-dessus (voir
.docList li dans document-editor.css). Une valeur UNIFORME partagée
par tous les éléments (retour utilisateur du 26/09/2026), jamais
réglable par élément individuel."""
parts = []
item_padding = str(a.get("item_padding", "")).strip()
if item_padding:
parts.append(f"--doc-item-padding:{html_lib.escape(item_padding)};")
item_spacing = str(a.get("item_spacing", "")).strip()
if item_spacing:
parts.append(f"--doc-item-spacing:{html_lib.escape(item_spacing)};")
return parts
def _render_list(el: dict[str, Any], _children_by_parent: dict[int | None, list[dict[str, Any]]]) -> str:
"""Liste à puces (<ul>) ou numérotée (<ol>) — le kind lui-même décide
la balise, pas un attribut "ordered" séparé (voir
element_kind_labels.element_default_attributes). Une "items" vide
rend une liste vide plutôt qu'un placeholder : contrairement à une
image sans fichier, une liste sans élément n'a rien d'anormal à
afficher (le créateur vient peut-être de tout supprimer avant d'en
retaper un)."""
a = el["attributes"]
kind = el["kind"]
items = a.get("items", [])
tag = "ol" if kind == "liste_numerotee" else "ul"
items_html = "".join(f"<li>{html_lib.escape(str(item))}</li>" for item in items)
style = " ".join(
_list_text_style(a)
+ _list_marker_style(a, kind)
+ _list_item_style(a)
+ [render_box_style(a), render_content_align(a)]
)
style = style.strip()
style_attr = f' style="{style}"' if style else ""
return f'<{tag} class="docList" data-element-id="{el["id"]}" data-kind="{kind}"{style_attr}>{items_html}</{tag}>'
_BUTTON_TEXT_TRANSFORMS = ("uppercase", "lowercase", "capitalize")
def _button_text_style(a: dict[str, Any]) -> list[str]:
"""Déclarations de typographie propres au bouton (jamais dans
box_style.py, partagé avec d'autres kinds qui n'ont pas tous une
notion de texte)."""
parts = []
if a.get("bold"):
# 800 (jamais 700, déjà le poids par défaut du CSS de base) :
# "gras" ne fait que RENFORCER le poids existant, jamais
# l'affaiblir — aucun bouton déjà créé ne change d'apparence tant
# que cette case n'est pas cochée explicitement.
parts.append("font-weight:800;")
if a.get("italic"):
parts.append("font-style:italic;")
text_transform = str(a.get("text_transform", "none"))
if text_transform in _BUTTON_TEXT_TRANSFORMS:
parts.append(f"text-transform:{text_transform};")
font_family = str(a.get("font_family", "")).strip()
if font_family:
parts.append(f"font-family:{html_lib.escape(font_family)};")
font_size = str(a.get("font_size", "")).strip()
if font_size:
parts.append(f"font-size:{html_lib.escape(font_size)};")
letter_spacing = str(a.get("letter_spacing", "")).strip()
if letter_spacing:
parts.append(f"letter-spacing:{html_lib.escape(letter_spacing)};")
text_color = str(a.get("text_color", "")).strip()
if text_color:
parts.append(f"color:{html_lib.escape(text_color)};")
return parts
def _button_icon_html(a: dict[str, Any]) -> str:
svg_markup = str(a.get("svg_markup", "")).strip()
if not svg_markup:
return ""
icon_size = str(a.get("icon_size", "")).strip()
size_style = (
f' style="width:{html_lib.escape(icon_size)}; height:{html_lib.escape(icon_size)};"' if icon_size else ""
)
return f'<span class="docButtonIcon"{size_style}>{sanitize_svg_markup(svg_markup)}</span>'
def _render_button(el: dict[str, Any], _children_by_parent: dict[int | None, list[dict[str, Any]]]) -> str:
@@ -79,9 +391,70 @@ def _render_button(el: dict[str, Any], _children_by_parent: dict[int | None, lis
label = html_lib.escape(str(a.get("label", "Bouton")))
target = html_lib.escape(str(a.get("target", "")))
target_attr = f' data-target="{target}"' if target else ""
# `data-attachment-filename` sert UNIQUEMENT de marqueur mécanique : un
# fichier a bien été joint (voir routes/document/
# document_element_upload_attachment.py). L'URL de téléchargement
# elle-même n'est jamais construite ici (ce renderer ne connaît pas le
# slug du support) — static/document/js/document-editor.js l'assemble
# à partir de `data-element-id` + FORGE_DOCUMENT.slug, même principe
# que le reste des appels AJAX de l'éditeur.
attachment_filename = html_lib.escape(str(a.get("attachment_filename", "")))
attachment_attr = f' data-attachment-filename="{attachment_filename}"' if attachment_filename else ""
style = " ".join(_button_text_style(a) + [render_box_style(a)]).strip()
style_attr = f' style="{style}"' if style else ""
icon_html = _button_icon_html(a)
label_span = f'<span class="docButtonLabel">{label}</span>'
inner = (
f"{label_span}{icon_html}" if str(a.get("icon_position", "before")) == "after" else f"{icon_html}{label_span}"
)
return (
f'<button type="button" class="docButton" data-element-id="{el["id"]}" data-kind="bouton"{target_attr}>'
f"{label}</button>"
f'<button type="button" class="docButton" data-element-id="{el["id"]}" '
f'data-kind="bouton"{target_attr}{attachment_attr}{style_attr}>{inner}</button>'
)
def _render_badge(el: dict[str, Any], _children_by_parent: dict[int | None, list[dict[str, Any]]]) -> str:
a = el["attributes"]
content = html_lib.escape(str(a.get("content", "")))
svg_markup = str(a.get("svg_markup", "")).strip()
icon_html = f'<span class="docBadgeIcon">{sanitize_svg_markup(svg_markup)}</span>' if svg_markup else ""
style_parts = []
width = str(a.get("width", "")).strip()
if width:
# Fixer une largeur implique de ne plus s'étirer sur toute la
# largeur de .docPageContent (comportement par défaut d'un enfant
# flex en colonne, voir static/document/document-editor.css) —
# les deux vont toujours ensemble, jamais l'un sans l'autre.
style_parts.append(f"align-self:flex-start; width:{html_lib.escape(width)};")
border_radius = str(a.get("border_radius", "")).strip()
if border_radius:
style_parts.append(f"border-radius:{html_lib.escape(border_radius)};")
if a.get("bold"):
style_parts.append("font-weight:800;")
if a.get("uppercase"):
style_parts.append("text-transform:uppercase;")
style_attr = f' style="{" ".join(style_parts)}"' if style_parts else ""
return (
f'<div class="docBadge" data-element-id="{el["id"]}" data-kind="badge"{style_attr}>{icon_html}{content}</div>'
)
def _render_carte(el: dict[str, Any], _children_by_parent: dict[int | None, list[dict[str, Any]]]) -> str:
a = el["attributes"]
label = html_lib.escape(str(a.get("label", "")))
title = html_lib.escape(str(a.get("title", "")))
description = html_lib.escape(str(a.get("description", "")))
return (
f'<div class="docCard" data-element-id="{el["id"]}" data-kind="carte">'
f'<div class="docCardLabel">{label}</div>'
f'<div class="docCardTitle">{title}</div>'
f'<div class="docCardDescription">{description}</div>'
f"</div>"
)
@@ -498,6 +871,10 @@ _RENDERERS = {
"paragraphe": _render_text,
"image": _render_image,
"bouton": _render_button,
"liste_puces": _render_list,
"liste_numerotee": _render_list,
"badge": _render_badge,
"carte": _render_carte,
"quiz": _render_quiz,
"association": _render_association,
"memory": _render_memory,
+204 -3
View File
@@ -29,9 +29,125 @@ regroupement à chaque appel.
`justify-content` réels depuis `attributes`), enfants rendus
récursivement.
- **Texte** (`titre`/`paragraphe`) : `<div>` stylé selon `style` (préréglage
taille/graisse/interligne) et `bold`/`italic`/`underline`/`align`/`color`.
- **Image** : `<img>`, ou un bloc placeholder si `src` est vide.
- **Bouton** : `<button>` avec son `label` et un `data-target` optionnel.
taille/graisse/interligne) et `bold`/`italic`/`underline`/`strikethrough`/
`align`/`color`. `underline`/`strikethrough` se combinent dans un seul
`text-decoration` (`"underline line-through"` si les deux sont actifs).
`font_size`/`line_height` (vides par défaut) remplacent les valeurs du
préréglage `style` SANS toucher `font-weight` (toujours piloté par le
préréglage + `bold`). `text_transform` (`"none"` par défaut) ajoute
`text-transform` quand différent de `"none"`. `font_family`/
`letter_spacing`/`text_shadow` (vides par défaut) ajoutent leur
déclaration CSS respective quand non vides. `max_width` (optionnel, ex.
`"60ch"`, `"480px"`) ajoute `max-width` au style inline quand non vide —
pleine largeur de `.docPageContent` par défaut, retour utilisateur du
24/09/2026 (un paragraphe doit pouvoir rester plus étroit que la page,
sans dépendre d'une rangée qui en partagerait la largeur avec un frère).
Termine par `render_box_style(a)` (voir `box_style.py` ci-dessous) pour
`padding`/`margin`/`background_color`/`border_radius`/`border`/
`align_self` — attributs PARTAGÉS avec d'autres kinds, jamais dupliqués
ici (audit du 26/09/2026, réglages manquants à couvrir élément par
élément en réutilisant ce module).
- **Image** : `<img>`, ou un bloc placeholder si `src` est vide — OU, si
`attributes["svg_markup"]` est non vide (prioritaire sur `src`), un
`<div>` portant directement ce fragment SVG nettoyé par
`sanitize_svg_markup` (voir `sanitize_svg_markup.py` ci-dessous) : un
contenu vectoriel dessiné/collé par le créateur plutôt qu'un fichier
hébergé. Style inline : `object_fit` (`"cover"`/`"contain"`/`"fill"`,
toute autre valeur ignorée), `aspect_ratio` (valeur CSS libre, ex.
`"16 / 9"`), `filter_preset` (`"grayscale"`/`"sepia"`/`"blur"`, mappé
vers une vraie valeur `filter` CSS fixe — jamais une valeur de filtre
libre) + les attributs de boîte partagés (`render_box_style`, voir
`box_style.py`). `lazy_load` (`True`) ajoute `loading="lazy"` sur
l'`<img>` uniquement (comportement, pas du style). `click_behavior`
(`""`/`"link"`/`"lightbox"`) enveloppe le tout dans un `<a target="_blank"
rel="noopener noreferrer">` (si `link_url` est aussi renseigné) ou un
`<div class="docImageLightboxTrigger">` — les deux ne deviennent
réellement cliquables qu'en Mode Aperçu (voir static/document/js/
document-editor.js::forgeDocBindCanvasInteractions/
forgeDocOpenImageLightbox), même principe que les mini-jeux et la
pièce jointe d'un bouton. `caption` (non vide) enveloppe le tout dans
un `<figure><figcaption>` échappée.
- **Bouton** : `<button>` avec son `label` (enveloppé dans
`<span class="docButtonLabel">`), un `data-target` optionnel
(navigation) et un `data-attachment-filename` optionnel — marqueur
mécanique posé quand un fichier a été joint (voir
`routes/document/document_element_upload_attachment.py`), jamais
l'URL de téléchargement elle-même (ce renderer ne connaît pas le slug
du support ; `static/document/js/document-editor.js` l'assemble à
partir de `data-element-id` + `FORGE_DOCUMENT.slug`, même principe que
le reste des appels AJAX de l'éditeur). Style inline (audit du
26/09/2026, réglages manquants — colonne "Bouton") : `bold` pousse
`font-weight` à `800` (jamais en dessous du `700` déjà posé par le CSS
de base — "gras" ne fait que renforcer, jamais affaiblir, aucune
régression visuelle sur les boutons déjà créés), `italic`/
`text_transform` (`"uppercase"`/`"lowercase"`/`"capitalize"`, jamais
`"none"`)/`font_family`/`font_size`/`letter_spacing`/`text_color`
(vides par défaut) + les attributs de boîte partagés
(`render_box_style`, voir `box_style.py`). `svg_markup` (optionnel,
nettoyé par `sanitize_svg_markup`) ajoute un `<span
class="docButtonIcon">` avant OU après `.docButtonLabel` selon
`icon_position` (`"before"` par défaut), dimensionné par `icon_size`
(vide = `1em`, suit la taille du texte). États interactifs
survol/actif : effet CSS universel (`filter`/`transform`, voir
`static/document/document-editor.css`), jamais configurable par
attribut — pas de notion d'état "désactivé" pour un bouton de contenu
(ce n'est pas un vrai contrôle de formulaire).
- **Étiquette** (`badge`) : `<div>` portant `attributes["content"]`
échappé, précédé d'un `<span class="docBadgeIcon">` optionnel si
`svg_markup` est non vide (nettoyé par `sanitize_svg_markup`, même
mécanisme que le mode SVG de `"image"`). `width` (non vide) ajoute
`align-self:flex-start; width:{valeur};` en style inline — fixer une
largeur implique TOUJOURS de sortir de l'étirement pleine largeur par
défaut d'un enfant flex en colonne (voir static/document/
document-editor.css, `.docPageContent`), jamais l'un sans l'autre.
`border_radius` (non vide) ajoute `border-radius:{valeur};`. `bold`/
`uppercase` ajoutent respectivement `font-weight:800;`/
`text-transform:uppercase;` quand `True`. Tous ces attributs sont vides
ou `False` par défaut (comportement historique inchangé, aucun style
inline ajouté).
- **Carte** (`carte`) : `<div>` composé de trois blocs enfants
(`label`/`title`/`description`, tous échappés) — contenu pur, aucune
couleur/forme choisie ici (voir `element_kind_labels.md`).
- **Liste à puces/numérotée** (`liste_puces`/`liste_numerotee`) :
`<ul>` ou `<ol>` selon le `kind` (fonction privée `_render_list`,
partagée par les deux) — un `<li>` par entrée de `attributes["items"]`.
Une liste vide rend `<ul>`/`<ol>` sans enfant plutôt qu'un placeholder :
contrairement à une image sans fichier, ce n'est pas un état anormal.
Style inline (audit du 26/09/2026, portée actée avec l'utilisateur :
la LISTE ENTIÈRE uniquement, jamais par élément individuel ni de
sous-listes) : `bold`/`italic`/`underline`/`font_family`/`font_size`/
`line_height`/`text_color` (typo, `_list_text_style`) +
`list_style_type` (validé selon le `kind` — `disc`/`circle`/`square`/
`none` pour `liste_puces`, `decimal`/`decimal-leading-zero`/
`lower-roman`/`upper-roman`/`lower-alpha`/`upper-alpha`/`none` pour
`liste_numerotee`, toute autre valeur ignorée)/`list_style_position`
(`"outside"` par défaut) + les attributs de boîte partagés
(`render_box_style`, voir `box_style.py`). `marker_color`/
`marker_size`/`item_padding`/`item_spacing` (`_list_marker_style`/
`_list_item_style`) passent par des PROPRIÉTÉS PERSONNALISÉES CSS
(`--doc-marker-color`/`--doc-marker-size`/`--doc-item-padding`/
`--doc-item-spacing`) : un style inline sur le `<ul>`/`<ol>` ne peut
pas cibler directement le `::marker` ou le padding de ses `<li>`
enfants autrement — ces propriétés sont posées sur le conteneur et
consommées par `static/document/document-editor.css`
(`.docList li`/`.docList li::marker`), qui hérite jusque-là.
`svg_markup` (liste à puces UNIQUEMENT, ignoré pour `liste_numerotee`)
: puce personnalisée — nettoyé par `sanitize_svg_markup` puis encodé
en URI de données (`urllib.parse.quote`) pour `list-style-image`, qui
prime visuellement sur `list_style_type` dès qu'il est posé.
`render_content_align(a)` (voir `box_style.py`) également ajouté :
`.docList` est `display:flex; flex-direction:column;` (un `<li>` garde
son `display:list-item` propre — puce/numéro visibles — même une fois
flex-item, ce sont deux notions indépendantes en CSS).
**Bug réel corrigé** (retour utilisateur du 26/09/2026 : "si j'enlève
les puces ou que les puces se mettent à l'intérieur, il reste un
espace devant la liste, cet espace doit être supprimé") :
`padding-left:0;` est ajouté automatiquement dès que
`list_style_type="none"` ou `list_style_position="inside"` — le
`padding-left:1.4em` par défaut (`.docList`, réservé pour une puce
EXTÉRIEURE) n'a alors plus lieu d'être ; un `padding` uniforme réglé
explicitement par ailleurs (attributs de boîte partagés) reste
prioritaire (déclaré après, dans le même style inline).
- **Quiz** : toujours une carte résumant la config réelle (nombre de
questions, total des points via `quiz_total_points`, minuteur si
activé) — sanitizée (`sanitize_quiz_config`) avant lecture, jamais un
@@ -141,3 +257,88 @@ regroupement à chaque appel.
du type (voir `document_engine/labels/element_kind_labels.py`) —
emplacement réservé, formulaire de contenu dédié hors périmètre de
cette passe.
## `box_style.py` — attributs de "boîte" partagés entre plusieurs kinds
Audit du 26/09/2026 (réglages manquants, à couvrir élément par élément) :
padding/margin/couleur de fond/arrondi/bordure par côté/position du bloc
sont des besoins IDENTIQUES pour la plupart des kinds de contenu — un
seul jeu d'attributs et une seule fonction de rendu ici, jamais réécrits
à chaque kind (voir `_render_text` pour le premier usage réel).
### `BORDER_SIDES: tuple[str, ...]`
`("top", "right", "bottom", "left")`.
### `default_border() -> dict[str, dict[str, str]]`
Un dict à 4 clés (`BORDER_SIDES`), chacune `{"style": "none", "width":
"1px", "color": "var(--doc-border)"}`.
- **Retour** : un NOUVEAU dict à chaque appel (jamais un littéral partagé
muté par référence entre deux éléments — même précaution que
`DEFAULT_QUIZ_CONFIG` côté `labels/`).
- **Exceptions** : aucune.
### `BOX_DEFAULTS: dict[str, Any]`
`{"padding": "", "margin": "", "background_color": "", "border_radius":
"", "align_self": "stretch", "width": "", "max_width": "", "height": "",
"min_height": "", "max_height": "", "min_width": "", "box_shadow": "",
"opacity": "", "content_align": "top"}` — `border` n'y figure PAS (voir
`default_border()`, à ajouter séparément par chaque appelant pour éviter
le partage par référence).
### `render_box_style(a: dict[str, Any]) -> str`
Construit les déclarations CSS inline pour chaque attribut listé dans
`BOX_DEFAULTS` (padding/margin/background_color/border_radius/width/
max_width/height/min_height/max_height/min_width/box_shadow/opacity) +
`border`/`align_self` de `a`, via une table de correspondance
(clé d'attribut, propriété CSS) plutôt qu'un bloc `if` par attribut
(complexité cognitive — voir `_render_simple_properties`/`_render_border`,
privées) — un attribut absent ou à sa valeur par défaut ne produit
AUCUNE déclaration (comportement historique inchangé). `border` est un
dict à 4 clés
(`BORDER_SIDES`), chacune `{"style", "width", "color"}` — un côté à
`style="none"` (ou absent) ne produit rien pour ce côté, jamais un
`border-top:none` explicite. `align-self` n'est ajouté que si différent
de `"stretch"` (déjà le comportement par défaut d'un enfant flex en
colonne).
- **Retour** : les déclarations CSS (`"propriete:valeur; ..."`), chaîne
vide si rien à ajouter.
- **Exceptions** : aucune.
### `render_content_align(a: dict[str, Any]) -> str`
Alignement vertical du CONTENU à l'intérieur de son propre bloc (retour
utilisateur du 26/09/2026 : "je peux augmenter la hauteur d'un
conteneur mais pas l'alignement vertical à l'intérieur") — `content_align`
(`"top"` par défaut, `"center"` ou `"bottom"`) mappé vers
`justify-content` (`""` pour `"top"`, comportement historique inchangé).
Jamais fusionné dans `render_box_style` : contrairement à `align_self`
(position du BLOC dans SON parent, valable pour tout consommateur),
l'alignement du CONTENU dépend de l'axe interne du conteneur —
`justify-content` convient à un conteneur en colonne (`_render_text`/
`_render_list`, dont les classes CSS `.docText`/`.docList` sont
`display:flex; flex-direction:column;`, et la figure d'une image
légendée, déjà flex-colonne), mais serait FAUX pour le Bouton (rangée
icône+texte : l'axe vertical y est déjà géré par `align-items`, voir
`static/document/document-editor.css`, `.docButton`) — chaque renderer
qui veut ce comportement l'appelle donc explicitement lui-même.
- **Retour** : `""` si absent/`"top"`/valeur inconnue, sinon
`"justify-content:...;"`.
- **Exceptions** : aucune.
## `sanitize_svg_markup.py` — nettoyage du contenu SVG inline d'une image
### `sanitize_svg_markup(markup: str) -> str`
Nettoie un fragment SVG selon une LISTE BLANCHE de balises/attributs
(`_ALLOWED_TAGS`/`_ALLOWED_ATTRS`, privées) — construit sur
`html.parser.HTMLParser` (tokenizer de balises pur, sans DTD ni
résolution d'entité externe) plutôt qu'un analyseur XML, qui resterait
exposé aux attaques classiques d'entité externe sur une entrée non
fiable. Toute balise absente de la liste blanche (`<script>`,
`<foreignObject>`, `<a>`, `<use>`, `<image>`...) disparaît AVEC son
contenu ; tout attribut absent (`on*`, `style`, `href`/`xlink:href`,
`class`...) disparaît seul, la balise porteuse étant conservée si elle
est autorisée. Appelé à CHAQUE rendu (`_render_image`), jamais
seulement à l'écriture — même défense en profondeur que
`html.escape` sur les autres kinds.
- **Retour** : le fragment SVG nettoyé, sûr à insérer tel quel dans le
HTML rendu.
- **Exceptions** : aucune.
@@ -0,0 +1,129 @@
"""Nettoyage d'un fragment SVG saisi/collé par le créateur comme contenu
d'une image (voir render_document_element._render_image) — jamais un
rendu direct de `attributes["svg_markup"]`, qui exposerait une injection
XSS triviale (`<script>`, `onload="..."`, `href="javascript:..."`).
Construit sur `html.parser.HTMLParser` (analyseur de balises pur, sans
DTD ni résolution d'entité externe) plutôt que sur un analyseur XML
(`xml.etree.ElementTree`), qui resterait exposé aux attaques classiques
d'entité externe/"milliard de rires" sur une entrée non fiable."""
from html.parser import HTMLParser
_ALLOWED_TAGS = {
"svg",
"path",
"circle",
"rect",
"line",
"polyline",
"polygon",
"ellipse",
"g",
"defs",
"lineargradient",
"radialgradient",
"stop",
"title",
"desc",
}
_ALLOWED_ATTRS = {
"viewbox",
"width",
"height",
"fill",
"stroke",
"stroke-width",
"stroke-linecap",
"stroke-linejoin",
"stroke-dasharray",
"opacity",
"fill-opacity",
"stroke-opacity",
"fill-rule",
"d",
"cx",
"cy",
"r",
"rx",
"ry",
"x",
"y",
"x1",
"y1",
"x2",
"y2",
"points",
"transform",
"offset",
"stop-color",
"stop-opacity",
}
class _SvgSanitizer(HTMLParser):
"""Reconstruit un fragment SVG balise par balise, en ne conservant que
les éléments/attributs de la liste blanche — jamais de liste noire
(une balise/un attribut absent de la liste blanche est TOUJOURS
supprimé, y compris un futur ajout du format SVG qu'on n'aurait pas
anticipé ici)."""
def __init__(self) -> None:
super().__init__(convert_charrefs=True)
self.output: list[str] = []
self._skip_depth = 0
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
self._open_tag(tag, attrs, self_closing=False)
def handle_startendtag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
self._open_tag(tag, attrs, self_closing=True)
def _open_tag(self, tag: str, attrs: list[tuple[str, str | None]], *, self_closing: bool) -> None:
tag = tag.lower()
if self._skip_depth or tag not in _ALLOWED_TAGS:
# Une balise auto-fermante (ex. <script/>) n'aura jamais de
# handle_endtag correspondant : incrémenter ici ferait fuir
# tout le reste du document dans un skip permanent.
if not self_closing:
self._skip_depth += 1
return
kept = [(name.lower(), value) for name, value in attrs if name.lower() in _ALLOWED_ATTRS]
attrs_html = "".join(f' {name}="{_escape_attr(value or "")}"' for name, value in kept)
self.output.append(f"<{tag}{attrs_html}{'/>' if self_closing else '>'}")
def handle_endtag(self, tag: str) -> None:
if self._skip_depth:
self._skip_depth -= 1
return
if tag.lower() in _ALLOWED_TAGS:
self.output.append(f"</{tag.lower()}>")
def handle_data(self, data: str) -> None:
if not self._skip_depth:
self.output.append(_escape_text(data))
def _escape_attr(value: str) -> str:
return value.replace("&", "&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.
+9
View File
@@ -8,12 +8,21 @@ from . import ( # noqa: F401 - enregistre les routes definies dans chaque modul
document_edit,
document_element_add,
document_element_delete,
document_element_download_attachment,
document_element_move,
document_element_move_to_page,
document_element_update,
document_element_upload_attachment,
document_element_upload_image,
document_new,
document_page_add,
document_page_delete,
document_page_delete_all,
document_page_move,
document_page_rename,
document_page_vertical_align,
document_render,
document_theme_apply,
document_theme_preview,
document_uploaded_file,
)
+27 -2
View File
@@ -37,12 +37,23 @@ def document_edit(slug: str) -> str:
constaté le 21/09/2026)."""
support = db.support_meta(slug)
pages = document_engine.list_document_pages(slug)
active_page = pages[0]
# active_page peut être None : un support peut avoir 0 page (retour
# utilisateur du 26/09/2026, voir document_engine/pages/pages.md) — le
# canevas et le panneau Pages doivent alors afficher un état "aucune
# page" plutôt que de planter, voir document_edit.html et
# document-editor.js (forgeDocSwitchPage/forgeDocRefreshCanvas).
active_page = pages[0] if pages else None
active_elements: list[dict[str, Any]] = [
active_elements: list[dict[str, Any]] = (
[
{**el, "attributes": document_engine.sanitize_element_attributes(el["kind"], el["attributes"])}
for el in document_engine.list_document_elements(slug, active_page["id"])
]
if active_page is not None
else []
)
active_theme = document_engine.get_document_theme_entry(support["theme"]) if support["theme"] else None
return render_template(
"document/document_edit.html",
@@ -53,4 +64,18 @@ def document_edit(slug: str) -> str:
rendered_document=document_engine.render_document(active_elements),
element_library=document_engine.ELEMENT_LIBRARY,
element_kind_labels=document_engine.ELEMENT_KIND_LABELS,
active_theme=active_theme,
# Seuls les champs utiles à la modale "Utiliser un modèle" côté
# client (voir static/document/js/document-editor.js) — jamais le
# `seed_pages` complet, inutilement volumineux et non nécessaire
# côté client (l'aperçu et l'application se font tous deux en
# appelant le serveur, voir document_theme_preview.py/
# document_theme_apply.py). Pas de route JSON dédiée pour une
# donnée 100% statique côté serveur : le catalogue tient déjà
# dans le contexte de cette page (voir document_engine/themes/
# theme_catalog.py).
document_themes=[
{"id": t["id"], "name": t["name"], "category": t["category"], "description": t["description"]}
for t in document_engine.DOCUMENT_THEMES
],
)
+16
View File
@@ -26,6 +26,22 @@ def document_element_add(slug: str) -> Response | tuple[Response, int]:
if page_id is None or document_engine.get_document_page(slug, page_id) is None:
return jsonify({"error": "page introuvable"}), 404
parent_id = request.form.get("parent_id", type=int)
# Un mini-jeu occupe toute la page, à lui seul (retour utilisateur du
# 23/09/2026 : "un mini jeu dois occupper toute une page" -> "une page
# avec mini-jeu = uniquement ce mini-jeu"). Vérifié ici, POINT D'ENTRÉE
# UNIQUE de tout ajout d'élément (bibliothèque, glisser-déposer,
# création de rangée à la volée, Annuler/Rétablir) : jamais dupliqué
# côté client, qui se contente d'afficher l'erreur renvoyée.
is_minigame = kind in document_engine.MINIGAME_KINDS
existing = document_engine.list_document_elements(slug, page_id)
if is_minigame and parent_id is not None:
return jsonify({"error": "Un mini-jeu ne peut pas être placé dans une rangée."}), 400
if is_minigame and existing:
return jsonify({"error": "Un mini-jeu doit être seul sur sa page — ajoutez-le sur une nouvelle page."}), 400
if not is_minigame and any(el["kind"] in document_engine.MINIGAME_KINDS for el in existing):
return jsonify({"error": "Cette page contient déjà un mini-jeu qui occupe toute la page."}), 400
element_id = document_engine.add_document_element(slug, kind, page_id=page_id, parent_id=parent_id)
element = db.assert_not_none(
document_engine.get_document_element(slug, element_id),
@@ -0,0 +1,30 @@
import os
from flask import send_from_directory
from werkzeug.exceptions import NotFound
from werkzeug.wrappers import Response
import db
import document_engine
from core.flask_app import app
@app.route("/document/<slug>/elements/<int:element_id>/download-attachment")
def document_element_download_attachment(slug: str, element_id: int) -> Response:
"""Sert le fichier joint à un bouton (voir
document_element_upload_attachment.py) sous son nom d'origine
(`download_name`), jamais sous son nom de stockage anonymisé
(`attachment_stored_name`, un UUID)."""
element = document_engine.get_document_element(slug, element_id)
if element is None:
raise NotFound
stored_name = str(element["attributes"].get("attachment_stored_name", ""))
original_filename = str(element["attributes"].get("attachment_filename", ""))
if not stored_name:
raise NotFound
return send_from_directory(
os.path.join(db.support_dir(slug), "attachments"),
stored_name,
as_attachment=True,
download_name=original_filename or stored_name,
)
@@ -0,0 +1,24 @@
from flask import jsonify, request
from werkzeug.wrappers import Response
import document_engine
from core.flask_app import app
@app.route("/document/<slug>/elements/<int:element_id>/move-to-page", methods=["POST"])
def document_element_move_to_page(slug: str, element_id: int) -> Response | tuple[Response, int]:
"""Déplace un élément vers une AUTRE page du support — appelée par la
pagination automatique côté client (retour utilisateur du 23/09/2026 :
"si il n'y a plus de place sur la page il faut automatiquement créer
une autre page [et y] coller le contenu", voir
static/document/js/document-editor.js, forgeDocCheckPageOverflow),
jamais par un glisser-déposer manuel (qui reste sur
document_element_move, réordonnancement dans la MÊME page)."""
payload = request.get_json(force=True) or {}
target_page_id = payload.get("target_page_id")
if not isinstance(target_page_id, int) or isinstance(target_page_id, bool):
return jsonify({"error": "page cible introuvable"}), 404
if document_engine.get_document_page(slug, target_page_id) is None:
return jsonify({"error": "page cible introuvable"}), 404
document_engine.move_document_element_to_page(slug, element_id, target_page_id)
return jsonify({"ok": True})
@@ -0,0 +1,54 @@
import os
import uuid
from typing import Any
from flask import jsonify, request
from werkzeug.wrappers import Response
import db
import document_engine
from core.flask_app import app
@app.route("/document/<slug>/elements/<int:element_id>/upload-attachment", methods=["POST"])
def document_element_upload_attachment(slug: str, element_id: int) -> Response | tuple[Response, int]:
"""Joint un fichier téléchargeable à un bouton (voir _render_button,
document_engine/rendering/render_document_element.py) — mirroir de
routes/uploads/upload_file.py côté jeu, mais stocké sous le dossier du
SUPPORT (db.support_dir) et réservé au kind "bouton" (un fichier joint
n'a de sens que pour déclencher un téléchargement au clic)."""
element = document_engine.get_document_element(slug, element_id)
if element is None:
return jsonify({"error": "élément introuvable"}), 404
if element["kind"] != "bouton":
return jsonify({"error": "seul un bouton peut recevoir un fichier joint"}), 400
f = request.files.get("file")
if not f or not f.filename:
return jsonify({"error": "Aucun fichier reçu"}), 400
original_filename = f.filename
ext = "".join(c for c in os.path.splitext(original_filename)[1].lower() if c.isalnum() or c == ".")[:10]
stored_name = uuid.uuid4().hex + ext
attachments_dir = os.path.join(db.support_dir(slug), "attachments")
os.makedirs(attachments_dir, exist_ok=True)
f.save(os.path.join(attachments_dir, stored_name))
attributes = {
**element["attributes"],
"attachment_stored_name": stored_name,
"attachment_filename": original_filename,
}
document_engine.update_document_element_attributes(slug, element_id, attributes)
element = db.assert_not_none(
document_engine.get_document_element(slug, element_id),
"element_id verifie present juste au-dessus, aucune suppression concurrente possible entre-temps ici",
)
elements_by_parent: dict[int | None, list[dict[str, Any]]] = {}
for el in document_engine.list_document_elements(slug, element["page_id"]):
elements_by_parent.setdefault(el["parent_id"], []).append(el)
return jsonify(
{
"ok": True,
"attributes": element["attributes"],
"rendered_html": document_engine.render_document_element(element, elements_by_parent),
}
)
@@ -0,0 +1,61 @@
import os
import uuid
from typing import Any
from flask import jsonify, request, url_for
from werkzeug.wrappers import Response
import db
import document_engine
from core.flask_app import app
_ALLOWED_IMAGE_EXTENSIONS = (".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg")
@app.route("/document/<slug>/elements/<int:element_id>/upload-image", methods=["POST"])
def document_element_upload_image(slug: str, element_id: int) -> Response | tuple[Response, int]:
"""Téléverse un fichier image pour un élément "image" (voir
_render_image, document_engine/rendering/render_document_element.py)
— mirroir de document_element_upload_attachment.py, mais stocké sous
`uploads/` (jamais `attachments/`, réservé au bouton) et réservé au
kind "image". `svg_markup` est vidé au passage : un fichier
téléversé implique `src`, jamais les deux modes en même temps (voir
element_default_attributes, `svg_markup` prioritaire sur `src` au
rendu — le vider ici évite qu'un ancien code SVG masque
silencieusement la photo qui vient d'être choisie)."""
element = document_engine.get_document_element(slug, element_id)
if element is None:
return jsonify({"error": "élément introuvable"}), 404
if element["kind"] != "image":
return jsonify({"error": "seul un élément image peut recevoir un fichier téléversé"}), 400
f = request.files.get("file")
if not f or not f.filename:
return jsonify({"error": "Aucun fichier reçu"}), 400
ext = os.path.splitext(f.filename)[1].lower()
if ext not in _ALLOWED_IMAGE_EXTENSIONS:
return jsonify({"error": "Format d'image non pris en charge (PNG, JPG, GIF, WEBP, SVG uniquement)"}), 400
stored_name = uuid.uuid4().hex + ext
uploads_dir = os.path.join(db.support_dir(slug), "uploads")
os.makedirs(uploads_dir, exist_ok=True)
f.save(os.path.join(uploads_dir, stored_name))
attributes = {
**element["attributes"],
"src": url_for("document_uploaded_file", slug=slug, filename=stored_name),
"svg_markup": "",
}
document_engine.update_document_element_attributes(slug, element_id, attributes)
element = db.assert_not_none(
document_engine.get_document_element(slug, element_id),
"element_id verifie present juste au-dessus, aucune suppression concurrente possible entre-temps ici",
)
elements_by_parent: dict[int | None, list[dict[str, Any]]] = {}
for el in document_engine.list_document_elements(slug, element["page_id"]):
elements_by_parent.setdefault(el["parent_id"], []).append(el)
return jsonify(
{
"ok": True,
"attributes": element["attributes"],
"rendered_html": document_engine.render_document_element(element, elements_by_parent),
}
)
+5 -7
View File
@@ -7,14 +7,12 @@ from core.flask_app import app
@app.route("/document/<slug>/pages/<int:page_id>/delete", methods=["POST"])
def document_page_delete(slug: str, page_id: int) -> Response | tuple[Response, int]:
"""Supprime une page — refuse de supprimer la DERNIÈRE page restante
(un support a toujours au moins une page, voir document_engine/pages/
pages.md) : le garde-fou vit ici, à la route, pas dans
delete_document_page (même découpage que routes/game/screens/
screen_delete.py côté jeu, qui bloque aussi au niveau route)."""
"""Supprime une page, y compris la dernière restante — un support à 0
page est un état valide (retour utilisateur du 26/09/2026 : "l'éditeur
ne dois plus etre obliger d'avoir une page active", voir
document_engine/pages/pages.md). Voir aussi document_page_delete_all.py
pour tout supprimer d'un coup."""
if document_engine.get_document_page(slug, page_id) is None:
return jsonify({"error": "page introuvable"}), 404
if len(document_engine.list_document_pages(slug)) <= 1:
return jsonify({"error": "impossible de supprimer la dernière page"}), 400
document_engine.delete_document_page(slug, page_id)
return jsonify({"ok": True})
@@ -0,0 +1,17 @@
from flask import jsonify
from werkzeug.wrappers import Response
import document_engine
from core.flask_app import app
@app.route("/document/<slug>/pages/delete-all", methods=["POST"])
def document_page_delete_all(slug: str) -> Response:
"""Supprime toutes les pages du support d'un coup (retour utilisateur :
"une option dans page pour supprimer toute les page d'un coup") — le
support se retrouve à 0 page, exactement comme un support neuf. Action
destructive et irréversible, jamais appelée sans confirmation
explicite côté client (voir static/document/js/document-editor.js,
forgeDocDeleteAllPages)."""
document_engine.delete_all_document_pages(slug)
return jsonify({"ok": True})
@@ -0,0 +1,22 @@
from flask import jsonify, request
from werkzeug.wrappers import Response
import document_engine
from core.flask_app import app
@app.route("/document/<slug>/pages/<int:page_id>/vertical-align", methods=["POST"])
def document_page_vertical_align(slug: str, page_id: int) -> Response | tuple[Response, int]:
"""Règle l'alignement vertical du contenu d'une page (voir panneau
Propriétés affiché quand l'onglet "Pages" est actif, static/document/
js/document-editor.js::forgeDocRenderPageProps) — renvoie la valeur
RÉELLEMENT persistée (jamais celle envoyée telle quelle) : une valeur
invalide retombe silencieusement sur "top", même convention que
document_page_rename.py pour un titre vide."""
if document_engine.get_document_page(slug, page_id) is None:
return jsonify({"error": "page introuvable"}), 404
payload = request.get_json(force=True) or {}
document_engine.set_document_page_vertical_align(slug, page_id, str(payload.get("vertical_align", "")))
page = document_engine.get_document_page(slug, page_id)
vertical_align = page["vertical_align"] if page else "top"
return jsonify({"ok": True, "vertical_align": vertical_align})
+11 -2
View File
@@ -20,10 +20,19 @@ def document_render(slug: str) -> Response | tuple[Response, int]:
document_edit.py : le panneau Propriétés reçoit `data.elements`
directement depuis cette route à chaque rafraîchissement du canevas."""
page_id = request.args.get("page_id", type=int)
if page_id is None or document_engine.get_document_page(slug, page_id) is None:
if page_id is None:
return jsonify({"error": "page introuvable"}), 404
page = document_engine.get_document_page(slug, page_id)
if page is None:
return jsonify({"error": "page introuvable"}), 404
elements = [
{**el, "attributes": document_engine.sanitize_element_attributes(el["kind"], el["attributes"])}
for el in document_engine.list_document_elements(slug, page_id)
]
return jsonify({"html": document_engine.render_document(elements), "elements": elements})
return jsonify(
{
"html": document_engine.render_document(elements),
"elements": elements,
"vertical_align": page["vertical_align"],
}
)
+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);
}
+79 -25
View File
@@ -5,6 +5,13 @@
{% block extra_head %}
<link rel="stylesheet"
href="{{ url_for('static', filename='document/document-editor.css') }}">
{% if active_theme %}
{% if active_theme.font_url %}
<link rel="stylesheet" href="{{ active_theme.font_url }}">
{% endif %}
<link rel="stylesheet"
href="{{ url_for('static', filename=active_theme.css_path) }}">
{% endif %}
{% endblock %}
{% block content %}
<div class="docEditor3" id="docEditor3" data-slug="{{ support.slug }}">
@@ -76,6 +83,23 @@
<circle cx="12" cy="12" r="3" />
</svg><span class="docBtnLabel">Aperçu</span>
</button>
<button type="button"
class="docBtnSecondary"
id="docTemplateBtn">
<svg width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round">
<rect x="3" y="3" width="7" height="7" rx="1" />
<rect x="14" y="3" width="7" height="7" rx="1" />
<rect x="3" y="14" width="7" height="7" rx="1" />
<rect x="14" y="14" width="7" height="7" rx="1" />
</svg><span class="docBtnLabel">Utiliser un modèle</span>
</button>
<div class="docDividerV"></div>
<div class="docWidthPicker"
id="docWidthPicker"
@@ -123,6 +147,15 @@
role="tabpanel"
aria-labelledby="docTabPages"
hidden>
{# Bouton d'ajout EN HAUT, toujours visible (retour utilisateur du
23/09/2026 : "le bouton pour ajouter une page dois être en haut
toujours visible" — avec beaucoup de pages, il disparaissait en
bas de la liste défilante). #}
<button type="button" class="docBtnSecondary docPageManagerAdd" id="docPageManagerAdd">+ Ajouter une page</button>
<button type="button"
class="docDeleteBtn docPageManagerDeleteAll"
id="docPageManagerDeleteAll"
{{ 'disabled' if not pages else '' }}>🗑 Supprimer toutes les pages</button>
<div class="docPageManagerList" id="docPageManagerList">
{% for page in pages %}
<div class="docPageRow {{ 'is-active' if page.id == active_page.id else '' }}"
@@ -131,29 +164,15 @@
draggable="true"
data-page-id="{{ page.id }}">
{# //NOSONAR Web:S6819,Web:MouseEventWithoutKeyboardEquivalentCheck - div+role=button
volontaire : la rangée contient de vrais <button> d'action (réordonner/renommer/
supprimer), qu'un <button> englobant ne pourrait pas contenir validement.
L'équivalent clavier (Entrée/Espace) est posé côté JS (forgeDocRenderPageManagerList),
donc le finding clavier de Sonar est un faux positif : l'analyseur statique ne
voit pas les addEventListener dynamiques. #}
volontaire : la rangée contient de vrais <button> d'action (renommer/supprimer),
qu'un <button> englobant ne pourrait pas contenir validement. L'équivalent clavier
(Entrée/Espace) est posé côté JS (forgeDocRenderPageManagerList), donc le finding
clavier de Sonar est un faux positif : l'analyseur statique ne voit pas les
addEventListener dynamiques. #}
<span class="docPageRowHandle" aria-hidden="true">⠿</span>
<span class="docPageRowNumber">{{ loop.index }}</span>
<span class="docPageRowTitle"><span class="docPageRowTitleText">{{ page.title }}</span></span>
<span class="docPageRowActions">
<button type="button"
class="docPageRowMove"
data-dir="up"
data-page-id="{{ page.id }}"
aria-label="Monter"
title="Monter"
{{ 'disabled' if loop.first else '' }}>↑</button>
<button type="button"
class="docPageRowMove"
data-dir="down"
data-page-id="{{ page.id }}"
aria-label="Descendre"
title="Descendre"
{{ 'disabled' if loop.last else '' }}>↓</button>
<button type="button"
class="docPageRowRename"
data-page-id="{{ page.id }}"
@@ -163,13 +182,11 @@
class="docPageRowDelete"
data-page-id="{{ page.id }}"
aria-label="Supprimer cette page"
title="Supprimer cette page"
{{ 'disabled' if pages|length <= 1 else '' }}>✕</button>
title="Supprimer cette page">✕</button>
</span>
</div>
{% endfor %}
</div>
<button type="button" class="docBtnSecondary docPageManagerAdd" id="docPageManagerAdd">+ Ajouter une page</button>
</div>
<div class="docSidebarTabPanel" id="docTabPanelLayout" role="tabpanel" aria-labelledby="docTabLayout">
{% for category_key, category in element_library.items() %}
@@ -191,9 +208,16 @@
</div>
<div class="docCanvasArea" id="docCanvasArea">
<div class="docPage" id="docPage">
<div class="docPageContent" id="docPageContent">{{ rendered_document|safe }}</div>
{# //NOSONAR S5247 - rendered_document vient de document_engine.render_document, qui échappe (html.escape) tout contenu utilisateur avant interpolation (voir document_engine/rendering/render_document_element.py) ; jamais de HTML brut non échappé ici #}
<div class="docPageContent"
id="docPageContent"
data-vertical-align="{{ active_page.vertical_align if active_page else 'top' }}">{% if active_page %}{{ rendered_document|safe }}{% else %}<div class="docEmptyState docEmptyPageState">Aucune page — cliquez sur « + Ajouter une page » pour commencer.</div>{% endif %}</div>
{# //NOSONAR S5247 - rendered_document vient de document_engine.render_document, qui échappe (html.escape) tout contenu utilisateur avant interpolation (voir document_engine/rendering/render_document_element.py) ; jamais de HTML brut non échappé ici. Le message "Aucune page" est un littéral, jamais un contenu utilisateur. #}
</div>
<button type="button"
class="docPreviewExitBtn"
id="docPreviewExitBtn"
aria-label="Retour à l'éditeur"
title="Retour à l'éditeur">✕ Retour à l'éditeur</button>
<div class="docZoomPill">
<button type="button" id="docZoomOut" aria-label="Zoom -">−</button>
<span id="docZoomVal">100%</span>
@@ -231,13 +255,43 @@
<div class="docModalBody" id="docScenarioTreeModalBody"></div>
</div>
</div>
<div class="docModalBackdrop" id="docTemplateModal">
<div class="docModalDialog docModalDialog--wide">
<div class="docModalHeader">
<span class="docModalTitle">Utiliser un modèle</span>
<button type="button"
class="docModalCloseBtn"
id="docTemplateModalClose"
aria-label="Fermer">✕</button>
</div>
<div class="docModalBody docTemplateModalBody">
<div class="docTemplateList" id="docTemplateList"></div>
<div class="docTemplatePreviewPane">
<iframe class="docTemplatePreviewFrame"
id="docTemplatePreviewFrame"
title="Aperçu du modèle"></iframe>
<div class="docTemplatePreviewEmpty"
id="docTemplatePreviewEmpty">Sélectionnez un modèle pour le visualiser.</div>
<div class="docTemplatePreviewActions"
id="docTemplatePreviewActions"
hidden>
<button type="button" class="docBtnSecondary" id="docTemplateKeepContentBtn">Utiliser mon contenu actuel</button>
<button type="button" class="docBtnSecondary docBtnPrimary" id="docTemplateUseContentBtn">Utiliser le contenu du modèle</button>
<button type="button" class="docBtnSecondary docBtnPrimary" id="docTemplateRemoveBtn" hidden>Retirer le modèle</button>
</div>
</div>
</div>
</div>
</div>
<script>
window.FORGE_DOCUMENT = {
slug: {{ support.slug|tojson }},
elements: {{ elements|tojson }},
elementKindLabels: {{ element_kind_labels|tojson }},
pages: {{ pages|tojson }},
activePageId: {{ active_page.id|tojson }}
activePageId: {{ (active_page.id if active_page else none)|tojson }},
themes: {{ document_themes|tojson }},
activeThemeId: {{ support.theme|tojson }}
};
</script>
<script src="{{ url_for('static', filename='document/js/document-editor.js') }}"></script>
@@ -0,0 +1,158 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<title>Aperçu — {{ theme.name }}</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="{{ url_for('static', filename='document/document-editor.css') }}">
{% if theme.font_url %}
<link rel="stylesheet" href="{{ theme.font_url }}">
{% endif %}
<link rel="stylesheet" href="{{ url_for('static', filename=theme.css_path) }}">
<style>
html, body {
margin: 0;
padding: 0;
height: 100%;
background: #efece4;
display: flex;
flex-direction: column;
overflow: hidden;
}
.previewPages {
flex: 1;
min-height: 0;
min-width: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
overflow: hidden;
}
/* La page réelle a une largeur/un ratio FIXES côté moteur
(--doc-page-width/aspect-ratio, voir static/document/
document-editor.css) — sans mise à l'échelle, elle déborde
verticalement de cette fenêtre d'aperçu plus petite qu'un canevas
d'édition en plein écran, et défile/rogne au lieu de tenir
entière (retour utilisateur du 24/09/2026). `forgeDocFitPreviewPage`
calcule un facteur d'échelle qui la fait TOUJOURS tenir
entièrement, sans barre de défilement. */
.previewPages .docPage {
flex-shrink: 0;
transform-origin: center center;
}
.previewPages .docPage[hidden] {
display: none;
}
.previewNav {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
gap: 14px;
padding: 10px;
background: #1a1b20;
color: #fff;
font: 600 13px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
.previewNav button {
border: none;
background: #2a2c33;
color: #fff;
border-radius: 6px;
padding: 6px 12px;
cursor: pointer;
font: inherit;
}
.previewNav button:disabled {
opacity: 0.4;
cursor: default;
}
</style>
</head>
<body>
<div class="previewPages">
{% for page in pages %}
<div class="docPage" data-page-index="{{ loop.index0 }}" {% if not loop.first %}hidden{% endif %}>
<div class="docPageContent" data-vertical-align="{{ page.vertical_align }}">
{{ page.html | safe }}
</div>
</div>
{% endfor %}
</div>
{% if pages|length > 1 %}
<div class="previewNav">
<button type="button" id="previewPrevBtn">‹ Précédent</button>
<span id="previewPageLabel"></span>
<button type="button" id="previewNextBtn">Suivant ›</button>
</div>
{% endif %}
<script>
(function previewPageNav() {
var pages = Array.prototype.slice.call(document.querySelectorAll('.previewPages .docPage'));
if (pages.length <= 1) return;
var current = 0;
var prevBtn = document.getElementById('previewPrevBtn');
var nextBtn = document.getElementById('previewNextBtn');
var label = document.getElementById('previewPageLabel');
function render() {
pages.forEach(function pageAt(page, index) {
page.hidden = index !== current;
});
label.textContent = 'Page ' + (current + 1) + ' / ' + pages.length;
prevBtn.disabled = current === 0;
nextBtn.disabled = current === pages.length - 1;
}
prevBtn.addEventListener('click', function goPrev() {
if (current > 0) { current -= 1; render(); }
});
nextBtn.addEventListener('click', function goNext() {
if (current < pages.length - 1) { current += 1; render(); }
});
render();
}());
(function fitPreviewPage() {
// Toutes les pages partagent EXACTEMENT la même largeur/le même ratio
// fixes côté moteur (--doc-page-width/aspect-ratio) quel que soit leur
// contenu (voir static/document/document-editor.css) : mesurer une
// seule page suffit à connaître la taille "naturelle" de toutes.
var container = document.querySelector('.previewPages');
var pages = Array.prototype.slice.call(document.querySelectorAll('.previewPages .docPage'));
if (!container || !pages.length) return;
var reference = pages[0];
function applyFit() {
var wasHidden = reference.hidden;
var previousTransform = reference.style.transform;
reference.hidden = false;
reference.style.transform = 'none';
var naturalWidth = reference.offsetWidth;
var naturalHeight = reference.offsetHeight;
reference.hidden = wasHidden;
reference.style.transform = previousTransform;
if (!naturalWidth || !naturalHeight) return;
var availableWidth = container.clientWidth - 32;
var availableHeight = container.clientHeight - 32;
// Jamais agrandie au-delà de sa taille réelle (max 1) — un simple
// rétrécissement pour tenir, jamais un zoom artificiel.
var scale = Math.min(availableWidth / naturalWidth, availableHeight / naturalHeight, 1);
pages.forEach(function scalePage(page) {
page.style.transform = 'scale(' + scale + ')';
});
}
applyFit();
window.addEventListener('resize', applyFit);
}());
</script>
</body>
</html>
+11 -1
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
(mirroir de la fixture `game` ci-dessus, pour l'autre type de projet —
entité racine séparée, voir docs/plan/PLAN.md) et le supprime après le
test, quel que soit son résultat."""
test, quel que soit son résultat.
Un support neuf n'a plus aucune page par défaut (retour utilisateur
du 26/09/2026, voir document_engine/pages/pages.md) — cette fixture
lui en crée une par convénience, puisque la quasi-totalité des tests
existants portent sur du contenu et présupposent une première page
"Page 1" déjà là (comme avant ce changement). Les tests qui portent
spécifiquement sur l'état "0 page" créent leur propre support brut
via db.create_support(...) plutôt que d'utiliser cette fixture."""
import db.supports
import document_engine
resp = client.post("/documents/new", data={"name": "pytest_test_support"}, follow_redirects=False)
assert resp.status_code == 302
slug = resp.headers["Location"].rstrip("/").split("/")[-2]
document_engine.add_document_page(slug)
yield slug
if os.path.isdir(db.supports.support_dir(slug)):
db.delete_support(slug)
+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
+56 -6
View File
@@ -1,7 +1,10 @@
"""Routes HTTP des pages d'un support de formation (routes/document/
document_page_*.py) — retour utilisateur du 21/09/2026 : "il faut
implémenter un système de page". Un support a toujours au moins une page
(créée par défaut avec le support, voir la fixture `support`)."""
implémenter un système de page". La fixture `support` crée une première
page par convénience pour ces tests (voir tests/conftest.py) ; en
production un support neuf n'a plus aucune page, et supprimer la
dernière page restante est désormais autorisé (retour utilisateur du
26/09/2026, voir document_engine/pages/pages.md)."""
from flask.testing import FlaskClient
@@ -50,12 +53,29 @@ def test_document_page_rename_rejects_an_unknown_page(client: FlaskClient, suppo
assert client.post(f"/document/{support}/pages/999/rename", json={"title": "X"}).status_code == 404
def test_document_page_delete_refuses_the_last_remaining_page(client: FlaskClient, support: str) -> None:
def test_document_page_delete_allows_deleting_the_last_remaining_page(client: FlaskClient, support: str) -> None:
page_id = _page_id(support)
resp = client.post(f"/document/{support}/pages/{page_id}/delete")
assert resp.status_code == 400
assert "error" in resp.get_json()
assert document_engine.get_document_page(support, page_id) is not None
assert resp.status_code == 200
assert resp.get_json()["ok"] is True
assert document_engine.get_document_page(support, page_id) is None
assert document_engine.list_document_pages(support) == []
def test_document_page_delete_all_wipes_every_page_and_its_elements(client: FlaskClient, support: str) -> None:
first_page_id = _page_id(support)
second_page_id = client.post(f"/document/{support}/pages/add", json={}).get_json()["id"]
element_resp = client.post(f"/document/{support}/elements/add", data={"kind": "titre", "page_id": second_page_id})
element_id = element_resp.get_json()["id"]
resp = client.post(f"/document/{support}/pages/delete-all")
assert resp.status_code == 200
assert resp.get_json()["ok"] is True
assert document_engine.list_document_pages(support) == []
assert document_engine.get_document_page(support, first_page_id) is None
assert document_engine.get_document_page(support, second_page_id) is None
assert document_engine.get_document_element(support, element_id) is None
def test_document_page_delete_removes_a_non_last_page_and_its_elements(client: FlaskClient, support: str) -> None:
@@ -93,3 +113,33 @@ def test_document_page_move_reorders_the_tab_strip(client: FlaskClient, support:
def test_document_page_move_rejects_an_unknown_page(client: FlaskClient, support: str) -> None:
assert client.post(f"/document/{support}/pages/999/move", json={"new_index": 0}).status_code == 404
def test_new_page_defaults_to_top_vertical_align(client: FlaskClient, support: str) -> None:
page_id = _page_id(support)
page = document_engine.get_document_page(support, page_id)
assert page is not None
assert page["vertical_align"] == "top"
def test_document_page_vertical_align_persists_a_valid_value(client: FlaskClient, support: str) -> None:
page_id = _page_id(support)
resp = client.post(f"/document/{support}/pages/{page_id}/vertical-align", json={"vertical_align": "center"})
assert resp.status_code == 200
assert resp.get_json() == {"ok": True, "vertical_align": "center"}
page = document_engine.get_document_page(support, page_id)
assert page is not None
assert page["vertical_align"] == "center"
def test_document_page_vertical_align_falls_back_to_top_for_an_invalid_value(client: FlaskClient, support: str) -> None:
page_id = _page_id(support)
client.post(f"/document/{support}/pages/{page_id}/vertical-align", json={"vertical_align": "center"})
resp = client.post(f"/document/{support}/pages/{page_id}/vertical-align", json={"vertical_align": "n-importe-quoi"})
assert resp.status_code == 200
assert resp.get_json()["vertical_align"] == "top"
def test_document_page_vertical_align_rejects_an_unknown_page(client: FlaskClient, support: str) -> None:
resp = client.post(f"/document/{support}/pages/999/vertical-align", json={"vertical_align": "center"})
assert resp.status_code == 404
+342
View File
@@ -8,6 +8,7 @@ pages elles-mêmes) — _page_id() renvoie l'id de la page par défaut
("Page 1") créée avec chaque support de test, réutilisé par toutes les
routes d'élément ci-dessous qui exigent désormais un page_id explicite."""
import io
from typing import Any
from flask.testing import FlaskClient
@@ -38,6 +39,19 @@ def test_document_edit_renders_directly_no_intermediate_page(client: FlaskClient
assert resp.status_code == 200
def test_document_edit_works_on_a_support_with_zero_pages(client: FlaskClient, support: str) -> None:
# Retour utilisateur du 26/09/2026 : "l'éditeur ne dois plus etre
# obliger d'avoir une page active ou créer, il peut etre ouvert sans
# aucune page" — un support neuf (ou vidé via "Supprimer toutes les
# pages") n'a plus de page active, l'éditeur ne doit pas planter.
client.post(f"/document/{support}/pages/delete-all")
resp = client.get(f"/document/{support}/edit")
assert resp.status_code == 200
html = resp.get_data(as_text=True)
assert "activePageId: null" in html
assert "pages: []" in html
def test_document_element_add_returns_rendered_html(client: FlaskClient, support: str) -> None:
resp = client.post(f"/document/{support}/elements/add", data={"kind": "titre", "page_id": _page_id(support)})
assert resp.status_code == 200
@@ -69,6 +83,25 @@ def test_document_element_add_accepts_row_for_the_layout_engine(client: FlaskCli
assert resp.get_json()["kind"] == "row"
def test_document_element_add_creates_a_bullet_list(client: FlaskClient, support: str) -> None:
resp = client.post(f"/document/{support}/elements/add", data={"kind": "liste_puces", "page_id": _page_id(support)})
assert resp.status_code == 200
payload = resp.get_json()
assert payload["kind"] == "liste_puces"
assert "<ul" in payload["rendered_html"]
assert "docList" in payload["rendered_html"]
def test_document_element_add_creates_a_numbered_list(client: FlaskClient, support: str) -> None:
resp = client.post(
f"/document/{support}/elements/add", data={"kind": "liste_numerotee", "page_id": _page_id(support)}
)
assert resp.status_code == 200
payload = resp.get_json()
assert payload["kind"] == "liste_numerotee"
assert "<ol" in payload["rendered_html"]
def test_document_element_update_persists_attributes(client: FlaskClient, support: str) -> None:
page_id = _page_id(support)
add_resp = client.post(f"/document/{support}/elements/add", data={"kind": "paragraphe", "page_id": page_id})
@@ -204,6 +237,60 @@ def test_document_render_rejects_a_missing_or_unknown_page(client: FlaskClient,
assert client.get(f"/document/{support}/render", query_string={"page_id": 999}).status_code == 404
def test_document_render_includes_the_page_vertical_align(client: FlaskClient, support: str) -> None:
page_id = _page_id(support)
client.post(f"/document/{support}/pages/{page_id}/vertical-align", json={"vertical_align": "center"})
resp = client.get(f"/document/{support}/render", query_string={"page_id": page_id})
assert resp.status_code == 200
assert resp.get_json()["vertical_align"] == "center"
def test_document_edit_reflects_the_active_page_vertical_align(client: FlaskClient, support: str) -> None:
page_id = _page_id(support)
client.post(f"/document/{support}/pages/{page_id}/vertical-align", json={"vertical_align": "center"})
resp = client.get(f"/document/{support}/edit")
assert 'data-vertical-align="center"' in resp.get_data(as_text=True)
def test_document_element_add_rejects_a_minigame_on_a_non_empty_page(client: FlaskClient, support: str) -> None:
"""Retour utilisateur du 23/09/2026 : "un mini jeu dois occupper
toute une page" -> "une page avec mini-jeu = uniquement ce
mini-jeu". Un mini-jeu ne peut donc jamais rejoindre une page qui a
déjà du contenu."""
page_id = _page_id(support)
client.post(f"/document/{support}/elements/add", data={"kind": "titre", "page_id": page_id})
resp = client.post(f"/document/{support}/elements/add", data={"kind": "quiz", "page_id": page_id})
assert resp.status_code == 400
assert "mini-jeu" in resp.get_json()["error"]
def test_document_element_add_rejects_other_content_on_a_page_with_a_minigame(
client: FlaskClient, support: str
) -> None:
page_id = _page_id(support)
client.post(f"/document/{support}/elements/add", data={"kind": "quiz", "page_id": page_id})
resp = client.post(f"/document/{support}/elements/add", data={"kind": "titre", "page_id": page_id})
assert resp.status_code == 400
assert "mini-jeu" in resp.get_json()["error"]
def test_document_element_add_rejects_a_minigame_inside_a_row(client: FlaskClient, support: str) -> None:
page_id = _page_id(support)
row_id = client.post(f"/document/{support}/elements/add", data={"kind": "row", "page_id": page_id}).get_json()["id"]
resp = client.post(
f"/document/{support}/elements/add", data={"kind": "quiz", "page_id": page_id, "parent_id": row_id}
)
assert resp.status_code == 400
def test_document_element_add_allows_a_lone_minigame_on_an_empty_page(client: FlaskClient, support: str) -> None:
resp = client.post(f"/document/{support}/elements/add", data={"kind": "quiz", "page_id": _page_id(support)})
assert resp.status_code == 200
def test_document_element_move_and_delete(client: FlaskClient, support: str) -> None:
page_id = _page_id(support)
first_id = client.post(f"/document/{support}/elements/add", data={"kind": "titre", "page_id": page_id}).get_json()[
@@ -225,6 +312,35 @@ def test_document_element_move_and_delete(client: FlaskClient, support: str) ->
assert delete_resp.get_json()["ok"] is True
def test_document_element_move_to_page_transfers_the_element(client: FlaskClient, support: str) -> None:
"""Retour utilisateur du 23/09/2026 : pagination automatique — un
élément qui déborde d'une page est déplacé vers une autre, ici la
route qui porte ce déplacement (jamais document_element_move, qui
ne fait que réordonner DANS la même page)."""
page1_id = _page_id(support)
page2_id = document_engine.add_document_page(support)
element_id = client.post(
f"/document/{support}/elements/add", data={"kind": "titre", "page_id": page1_id}
).get_json()["id"]
resp = client.post(f"/document/{support}/elements/{element_id}/move-to-page", json={"target_page_id": page2_id})
assert resp.status_code == 200
assert resp.get_json()["ok"] is True
moved = document_engine.get_document_element(support, element_id)
assert moved is not None
assert moved["page_id"] == page2_id
def test_document_element_move_to_page_rejects_an_unknown_target(client: FlaskClient, support: str) -> None:
page_id = _page_id(support)
element_id = client.post(
f"/document/{support}/elements/add", data={"kind": "titre", "page_id": page_id}
).get_json()["id"]
resp = client.post(f"/document/{support}/elements/{element_id}/move-to-page", json={"target_page_id": 999})
assert resp.status_code == 404
def test_restricted_user_can_have_one_game_and_one_support_at_once(
user_client: FlaskClient, tmp_game_slug_cleanup: Any, tmp_support_slug_cleanup: Any
) -> None:
@@ -257,3 +373,229 @@ def test_cannot_open_another_owners_support(client: FlaskClient, user_client: Fl
assert client.post(f"/document/{victim_slug}/delete").status_code == 403
finally:
db.delete_support(victim_slug)
def _add_bouton(client: FlaskClient, support: str) -> int:
resp = client.post(f"/document/{support}/elements/add", data={"kind": "bouton", "page_id": _page_id(support)})
return int(resp.get_json()["id"])
def test_upload_attachment_stores_file_and_updates_button_attributes(client: FlaskClient, support: str) -> None:
element_id = _add_bouton(client, support)
resp = client.post(
f"/document/{support}/elements/{element_id}/upload-attachment",
data={"file": (io.BytesIO(b"%PDF-1.4 fake pdf content"), "fiche-consignes.pdf")},
content_type="multipart/form-data",
)
assert resp.status_code == 200
payload = resp.get_json()
assert payload["attributes"]["attachment_filename"] == "fiche-consignes.pdf"
assert payload["attributes"]["attachment_stored_name"]
assert 'data-attachment-filename="fiche-consignes.pdf"' in payload["rendered_html"]
def test_upload_attachment_rejects_a_non_bouton_element(client: FlaskClient, support: str) -> None:
resp = client.post(f"/document/{support}/elements/add", data={"kind": "titre", "page_id": _page_id(support)})
element_id = resp.get_json()["id"]
resp = client.post(
f"/document/{support}/elements/{element_id}/upload-attachment",
data={"file": (io.BytesIO(b"peu importe"), "x.pdf")},
content_type="multipart/form-data",
)
assert resp.status_code == 400
def test_upload_attachment_rejects_a_missing_file(client: FlaskClient, support: str) -> None:
element_id = _add_bouton(client, support)
resp = client.post(f"/document/{support}/elements/{element_id}/upload-attachment", data={})
assert resp.status_code == 400
def test_download_attachment_serves_the_file_under_its_original_name(client: FlaskClient, support: str) -> None:
element_id = _add_bouton(client, support)
client.post(
f"/document/{support}/elements/{element_id}/upload-attachment",
data={"file": (io.BytesIO(b"%PDF-1.4 fake pdf content"), "fiche-consignes.pdf")},
content_type="multipart/form-data",
)
resp = client.get(f"/document/{support}/elements/{element_id}/download-attachment")
assert resp.status_code == 200
assert resp.data == b"%PDF-1.4 fake pdf content"
assert "fiche-consignes.pdf" in resp.headers["Content-Disposition"]
def test_download_attachment_404s_when_nothing_was_uploaded(client: FlaskClient, support: str) -> None:
element_id = _add_bouton(client, support)
resp = client.get(f"/document/{support}/elements/{element_id}/download-attachment")
assert resp.status_code == 404
def _add_image(client: FlaskClient, support: str) -> int:
resp = client.post(f"/document/{support}/elements/add", data={"kind": "image", "page_id": _page_id(support)})
return int(resp.get_json()["id"])
def test_upload_image_sets_src_and_clears_svg_markup(client: FlaskClient, support: str) -> None:
element_id = _add_image(client, support)
document_engine.update_document_element_attributes(support, element_id, {"svg_markup": "<svg></svg>"})
resp = client.post(
f"/document/{support}/elements/{element_id}/upload-image",
data={"file": (io.BytesIO(b"fake png bytes"), "photo.png")},
content_type="multipart/form-data",
)
assert resp.status_code == 200
payload = resp.get_json()
assert payload["ok"] is True
assert payload["attributes"]["svg_markup"] == ""
src = payload["attributes"]["src"]
assert f"/document/{support}/uploads/" in src
assert f'src="{src}"' in payload["rendered_html"]
def test_upload_image_rejects_a_non_image_element(client: FlaskClient, support: str) -> None:
element_id = _add_bouton(client, support)
resp = client.post(
f"/document/{support}/elements/{element_id}/upload-image",
data={"file": (io.BytesIO(b"peu importe"), "x.png")},
content_type="multipart/form-data",
)
assert resp.status_code == 400
def test_upload_image_rejects_a_missing_file(client: FlaskClient, support: str) -> None:
element_id = _add_image(client, support)
resp = client.post(f"/document/{support}/elements/{element_id}/upload-image", data={})
assert resp.status_code == 400
def test_upload_image_rejects_a_disallowed_extension(client: FlaskClient, support: str) -> None:
element_id = _add_image(client, support)
resp = client.post(
f"/document/{support}/elements/{element_id}/upload-image",
data={"file": (io.BytesIO(b"#!/bin/sh"), "script.sh")},
content_type="multipart/form-data",
)
assert resp.status_code == 400
def test_uploaded_file_serves_the_stored_image(client: FlaskClient, support: str) -> None:
element_id = _add_image(client, support)
resp = client.post(
f"/document/{support}/elements/{element_id}/upload-image",
data={"file": (io.BytesIO(b"fake png bytes"), "photo.png")},
content_type="multipart/form-data",
)
src = resp.get_json()["attributes"]["src"]
resp = client.get(src)
assert resp.status_code == 200
assert resp.data == b"fake png bytes"
def test_uploaded_file_404s_for_an_unknown_filename(client: FlaskClient, support: str) -> None:
resp = client.get(f"/document/{support}/uploads/inconnu.png")
assert resp.status_code == 404
def test_document_edit_has_no_theme_link_by_default(client: FlaskClient, support: str) -> None:
resp = client.get(f"/document/{support}/edit")
assert "document/themes/securite-incendie.css" not in resp.get_data(as_text=True)
def test_document_theme_apply_rejects_an_unknown_theme(client: FlaskClient, support: str) -> None:
resp = client.post(
f"/document/{support}/theme/apply",
json={"theme_id": "n-importe-quoi", "mode": "keep_content"},
)
assert resp.status_code == 404
def test_document_theme_apply_rejects_an_invalid_mode(client: FlaskClient, support: str) -> None:
resp = client.post(
f"/document/{support}/theme/apply",
json={"theme_id": "securite-incendie", "mode": "n-importe-quoi"},
)
assert resp.status_code == 400
def test_document_theme_apply_keep_content_only_sets_the_theme(client: FlaskClient, support: str) -> None:
page_id = _page_id(support)
client.post(f"/document/{support}/elements/add", data={"kind": "titre", "page_id": page_id})
resp = client.post(
f"/document/{support}/theme/apply",
json={"theme_id": "securite-incendie", "mode": "keep_content"},
)
assert resp.status_code == 200
assert db.get_document_theme(support) == "securite-incendie"
assert len(document_engine.list_document_elements(support, page_id)) == 1
edit_resp = client.get(f"/document/{support}/edit")
assert "document/themes/securite-incendie.css" in edit_resp.get_data(as_text=True)
def test_document_theme_apply_template_content_replaces_everything(client: FlaskClient, support: str) -> None:
page_id = _page_id(support)
client.post(f"/document/{support}/elements/add", data={"kind": "titre", "page_id": page_id})
resp = client.post(
f"/document/{support}/theme/apply",
json={"theme_id": "securite-incendie", "mode": "template_content"},
)
assert resp.status_code == 200
pages = document_engine.list_document_pages(support)
theme = document_engine.get_document_theme_entry("securite-incendie")
assert theme is not None
assert len(pages) == len(theme["seed_pages"])
def test_document_theme_apply_with_empty_theme_id_removes_the_theme(client: FlaskClient, support: str) -> None:
# Retour utilisateur du 26/09/2026 : la modale "Utiliser un modèle"
# propose une carte "Aucun modèle" pour "revenir à un document de
# base" — theme_id vide est le signal que la route interprète comme
# un retrait, jamais comme une erreur de validation.
page_id = _page_id(support)
client.post(f"/document/{support}/elements/add", data={"kind": "titre", "page_id": page_id})
client.post(
f"/document/{support}/theme/apply",
json={"theme_id": "securite-incendie", "mode": "keep_content"},
)
assert db.get_document_theme(support) == "securite-incendie"
resp = client.post(f"/document/{support}/theme/apply", json={"theme_id": ""})
assert resp.status_code == 200
assert resp.get_json() == {"ok": True, "theme_id": ""}
# Retire vraiment la ligne _meta (voir db/supports/remove_document_theme.py)
# — jamais une chaîne vide qui violerait le contrat documenté de
# get_document_theme ("None" = aucun thème jamais appliqué/retiré).
assert db.get_document_theme(support) is None
# Le contenu n'est JAMAIS touché en retirant le thème.
assert len(document_engine.list_document_elements(support, page_id)) == 1
edit_resp = client.get(f"/document/{support}/edit")
assert "document/themes/securite-incendie.css" not in edit_resp.get_data(as_text=True)
def test_document_theme_apply_with_empty_theme_id_ignores_mode(client: FlaskClient, support: str) -> None:
resp = client.post(f"/document/{support}/theme/apply", json={"theme_id": "", "mode": "n-importe-quoi"})
assert resp.status_code == 200
assert db.get_document_theme(support) is None
def test_document_theme_preview_renders_every_seed_page_with_navigation(client: FlaskClient, support: str) -> None:
resp = client.get(f"/document/{support}/theme/securite-incendie/preview")
assert resp.status_code == 200
html = resp.get_data(as_text=True)
theme = document_engine.get_document_theme_entry("securite-incendie")
assert theme is not None
page_count = len(theme["seed_pages"])
assert html.count('class="docPage"') == page_count
# Une seule page visible au chargement (les autres portent `hidden`,
# navigation gérée côté client — voir templates/document/
# document_theme_preview.html).
assert html.count("hidden>") == page_count - 1
assert "previewNav" in html
def test_document_theme_preview_404s_for_an_unknown_theme(client: FlaskClient, support: str) -> None:
resp = client.get(f"/document/{support}/theme/n-importe-quoi/preview")
assert resp.status_code == 404
+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"
)
@@ -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("") == ""
+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"
def test_new_support_has_no_page_by_default(tmp_support_slug_cleanup: Any) -> None:
# Retour utilisateur du 26/09/2026 : "l'éditeur ne dois plus etre
# obliger d'avoir une page active" — voir document_engine/pages/pages.md.
import document_engine
slug = db.create_support("Nouveau projet", owner_folder="52")
tmp_support_slug_cleanup(slug)
assert document_engine.list_document_pages(slug) == []
def test_list_supports_scopes_to_owner_and_excludes_games(
tmp_support_slug_cleanup: Any, tmp_game_slug_cleanup: Any
) -> None:
@@ -59,3 +69,48 @@ def test_delete_support_removes_it_from_the_listing(tmp_support_slug_cleanup: An
assert [s["slug"] for s in db.list_supports("48")] == [slug]
db.delete_support(slug)
assert db.list_supports("48") == []
def test_new_support_has_no_theme_by_default(tmp_support_slug_cleanup: Any) -> None:
slug = db.create_support("Sans thème", owner_folder="49")
tmp_support_slug_cleanup(slug)
assert db.get_document_theme(slug) is None
assert db.support_meta(slug)["theme"] is None
def test_set_document_theme_persists_and_is_readable_back(tmp_support_slug_cleanup: Any) -> None:
slug = db.create_support("Avec thème", owner_folder="50")
tmp_support_slug_cleanup(slug)
db.set_document_theme(slug, "securite-incendie")
assert db.get_document_theme(slug) == "securite-incendie"
assert db.support_meta(slug)["theme"] == "securite-incendie"
def test_set_document_theme_can_be_changed(tmp_support_slug_cleanup: Any) -> None:
slug = db.create_support("Change de thème", owner_folder="51")
tmp_support_slug_cleanup(slug)
db.set_document_theme(slug, "securite-incendie")
db.set_document_theme(slug, "autre-theme")
assert db.get_document_theme(slug) == "autre-theme"
def test_remove_document_theme_resets_to_none(tmp_support_slug_cleanup: Any) -> None:
# Retour utilisateur du 26/09/2026 : la modale "Utiliser un modèle"
# propose une carte "Aucun modèle" pour "revenir à un document de
# base" — get_document_theme doit redevenir None, jamais une chaîne
# vide (contrat documenté dans db/supports/get_document_theme.py).
slug = db.create_support("Retire son thème", owner_folder="54")
tmp_support_slug_cleanup(slug)
db.set_document_theme(slug, "securite-incendie")
assert db.get_document_theme(slug) == "securite-incendie"
db.remove_document_theme(slug)
assert db.get_document_theme(slug) is None
assert db.support_meta(slug)["theme"] is None
def test_remove_document_theme_is_a_noop_when_none_was_ever_set(tmp_support_slug_cleanup: Any) -> None:
slug = db.create_support("Jamais de thème", owner_folder="55")
tmp_support_slug_cleanup(slug)
db.remove_document_theme(slug)
assert db.get_document_theme(slug) is None
+12
View File
@@ -26,3 +26,15 @@ from core import (
)
_ = (auth_guard, csrf, csrf_guard, db_teardown_guard, jinja_filters, recovery_codes_flash)
# routes/document/document_theme_preview.py::document_theme_preview(slug, theme_id) —
# `slug` doit rester dans la signature (Flask appelle la vue avec un
# kwarg par segment <slug>/<theme_id> de la route, TypeError sinon), mais
# le corps de la fonction ne s'en sert pas : l'aperçu d'un thème ne
# dépend d'aucune donnée DU support, `slug` ne sert qu'à laisser
# core/auth_guard.py (générique sur `request.view_args.get("slug")`)
# vérifier la propriété avant d'atteindre la vue.
def _unused_but_required_route_param(slug: str) -> None:
_ = slug