diff --git a/db/__init__.py b/db/__init__.py
index 909df00f..34505ce3 100644
--- a/db/__init__.py
+++ b/db/__init__.py
@@ -80,6 +80,7 @@ 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
@@ -122,6 +123,7 @@ __all__ = [
"support_meta",
"delete_support",
"get_document_theme",
+ "remove_document_theme",
"set_document_theme",
"ONBOARDING_TYPES",
"DEFAULT_ONBOARDING_TYPE",
diff --git a/db/supports/__init__.py b/db/supports/__init__.py
index be218a81..a9ff3448 100644
--- a/db/supports/__init__.py
+++ b/db/supports/__init__.py
@@ -9,6 +9,7 @@ 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
@@ -25,6 +26,7 @@ __all__ = [
"get_document_theme",
"install_support_teardown_safety_net",
"list_supports",
+ "remove_document_theme",
"set_document_theme",
"split_slug",
"support_dir",
diff --git a/db/supports/remove_document_theme.py b/db/supports/remove_document_theme.py
new file mode 100644
index 00000000..572178e6
--- /dev/null
+++ b/db/supports/remove_document_theme.py
@@ -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()
diff --git a/docs/plan/AUDIT_MISE_EN_FORME.md b/docs/plan/AUDIT_MISE_EN_FORME.md
index 5ad1ebf5..f56ea0cb 100644
--- a/docs/plan/AUDIT_MISE_EN_FORME.md
+++ b/docs/plan/AUDIT_MISE_EN_FORME.md
@@ -298,7 +298,163 @@ 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 — à faire
+### 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 `
`/`` (impossible de cibler le `::marker` ou le
+padding des `
` 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 `
` 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
+ `
` 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
@@ -349,3 +505,41 @@ 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//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.
diff --git a/document_engine/labels/element_kind_labels.py b/document_engine/labels/element_kind_labels.py
index dfddbb40..619352be 100644
--- a/document_engine/labels/element_kind_labels.py
+++ b/document_engine/labels/element_kind_labels.py
@@ -165,7 +165,43 @@ def element_default_attributes(kind: str) -> dict[str, Any]:
# "ordonnée ou non" se lit directement sur le kind au moment du
# rendu (voir render_document_element._render_list), jamais un
# attribut "ordered" redondant à tenir synchronisé avec le kind.
- return {"items": ["Premier élément", "Deuxième élément"]}
+ #
+ # 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) :
diff --git a/document_engine/labels/labels.md b/document_engine/labels/labels.md
index 2b22fc9b..024a894c 100644
--- a/document_engine/labels/labels.md
+++ b/document_engine/labels/labels.md
@@ -47,10 +47,14 @@ Attributs posés à la création d'un élément de ce type (voir
- **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/border` — sont
- toujours les mêmes, voir `rendering/box_style.py` : tous vides,
- `False` ou `"none"`/`"stretch"` par défaut = comportement historique
- inchangé pour le kind qui les gagne) :
+ 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),
@@ -70,7 +74,18 @@ Attributs posés à la création d'un élément de ce type (voir
(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), badge
+ 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
diff --git a/document_engine/rendering/box_style.py b/document_engine/rendering/box_style.py
index 646b91d5..328c02a5 100644
--- a/document_engine/rendering/box_style.py
+++ b/document_engine/rendering/box_style.py
@@ -35,8 +35,11 @@ BOX_DEFAULTS = {
"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
@@ -104,3 +107,27 @@ def render_box_style(a: dict[str, Any]) -> str:
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 ""
diff --git a/document_engine/rendering/render_document_element.py b/document_engine/rendering/render_document_element.py
index a6daab90..63d71f7e 100644
--- a/document_engine/rendering/render_document_element.py
+++ b/document_engine/rendering/render_document_element.py
@@ -1,9 +1,10 @@
import html as html_lib
import json
import random
+import urllib.parse
from typing import Any
-from .box_style import render_box_style
+from .box_style import render_box_style, render_content_align
from .sanitize_svg_markup import sanitize_svg_markup
@@ -102,6 +103,9 @@ def _render_text(el: dict[str, Any], _children_by_parent: dict[int | None, list[
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'
{content}
'
@@ -199,7 +203,15 @@ def _render_image(el: dict[str, Any], _children_by_parent: dict[int | None, list
media = f'
{media}
'
if caption:
- figure_style_attr = f' style="{box_style}"' if box_style else ""
+ # 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' 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'{media}'
f'{html_lib.escape(caption)}'
@@ -208,6 +220,101 @@ def _render_image(el: dict[str, Any], _children_by_parent: dict[int | None, list
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
, voir .docList li::marker dans
+ document-editor.css) : un style inline posé sur le
/ 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
, jamais au
+ conteneur
/ 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 (
) ou numérotée () — le kind lui-même décide
la balise, pas un attribut "ordered" séparé (voir
@@ -216,10 +323,22 @@ def _render_list(el: dict[str, Any], _children_by_parent: dict[int | None, list[
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)."""
- items = el["attributes"].get("items", [])
- tag = "ol" if el["kind"] == "liste_numerotee" else "ul"
+ a = el["attributes"]
+ kind = el["kind"]
+ items = a.get("items", [])
+ tag = "ol" if kind == "liste_numerotee" else "ul"
items_html = "".join(f"
{html_lib.escape(str(item))}
" for item in items)
- return f'<{tag} class="docList" data-element-id="{el["id"]}" data-kind="{el["kind"]}">{items_html}{tag}>'
+
+ 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")
diff --git a/document_engine/rendering/rendering.md b/document_engine/rendering/rendering.md
index d0b8d4db..5e58ad5f 100644
--- a/document_engine/rendering/rendering.md
+++ b/document_engine/rendering/rendering.md
@@ -113,6 +113,41 @@ regroupement à chaque appel.
partagée par les deux) — un `
` par entrée de `attributes["items"]`.
Une liste vide rend `
`/`` 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 `
`/`` ne peut
+ pas cibler directement le `::marker` ou le padding de ses `
`
+ 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 `
` 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
@@ -246,9 +281,9 @@ Un dict à 4 clés (`BORDER_SIDES`), chacune `{"style": "none", "width":
`{"padding": "", "margin": "", "background_color": "", "border_radius":
"", "align_self": "stretch", "width": "", "max_width": "", "height": "",
"min_height": "", "max_height": "", "min_width": "", "box_shadow": "",
-"opacity": ""}` — `border` n'y figure PAS (voir `default_border()`, à
-ajouter séparément par chaque appelant pour éviter le partage par
-référence).
+"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
@@ -269,6 +304,26 @@ colonne).
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`
diff --git a/static/document/document-editor.css b/static/document/document-editor.css
index 3d118f90..487184ff 100644
--- a/static/document/document-editor.css
+++ b/static/document/document-editor.css
@@ -263,6 +263,16 @@ body.objectEditBody > main.content {
margin-bottom: 10px;
}
+/* Sépare visuellement, DANS un même panneau Propriétés, les champs qui
+ touchent au CONTENU (propre à chaque kind) de ceux qui touchent au
+ CONTENEUR (position/boîte, partagés — voir forgeDocRenderBoxFieldsHtml)
+ — retour utilisateur du 26/09/2026 : "sinon c'est pas compréhensible". */
+.docPropsSectionLabel {
+ margin-top: 20px;
+ padding-top: 14px;
+ border-top: 1px solid var(--doc-border);
+}
+
.docLibItem {
border: 1px solid var(--doc-border);
border-radius: 10px;
@@ -587,6 +597,16 @@ body.objectEditBody > main.content {
mots normaux au milieu sans raison) ne coupe QUE si le mot ne tient
vraiment pas sur une ligne. */
overflow-wrap: break-word;
+
+ /* flex-colonne (retour utilisateur du 26/09/2026 : "je peux augmenter
+ la hauteur d'un conteneur mais pas l'alignement vertical à
+ l'intérieur") : sans effet visuel tant que la hauteur du bloc
+ correspond à son contenu (comportement historique inchangé) —
+ n'entre en jeu que si une hauteur fixe/minimale (voir box_style.py)
+ dépasse la hauteur naturelle du texte, via justify-content
+ (render_content_align, appliqué uniquement si réglé). */
+ display: flex;
+ flex-direction: column;
}
.docImage.docImagePlaceholder {
@@ -687,8 +707,48 @@ img.docImage {
color: var(--forge-text);
}
+/* item_padding/item_spacing/marker_color/marker_size (retour utilisateur
+ du 26/09/2026, audit — colonne "Liste à puces/numérotée") : une
+ valeur inline sur le
/ ne peut pas cibler directement un
+ enfant ni son ::marker — passe par des PROPRIÉTÉS PERSONNALISÉES CSS,
+ posées en style inline sur le conteneur (voir _render_list,
+ document_engine/rendering/render_document_element.py) et héritées
+ jusqu'ici (les propriétés personnalisées traversent aussi bien les
+ descendants que leurs pseudo-éléments). Valeur par défaut = comportement
+ historique inchangé (4px entre éléments, pas de padding) tant qu'aucune
+ valeur n'est réglée. */
+.docList li {
+ padding: var(--doc-item-padding, 0);
+}
+
+/* Bug réel corrigé une première fois le 26/09/2026 (capture à l'appui,
+ thème "Sécurité incendie") en posant display:flex ICI sur
pour
+ que le badge en ::before d'un thème (voir static/document/themes/
+ securite-incendie.css) s'affiche À CÔTÉ du texte plutôt qu'AU-DESSUS.
+ **Corrigé une seconde fois** (retour utilisateur : "quand j'enlève le
+ thème [...] on voit plus les puces ou les numéros") : display:flex
+ sur le
lui-même REMPLACE entièrement son display:list-item —
+ ça supprime le marqueur natif (puce/numéro) pour TOUTE liste, avec ou
+ sans thème, pas seulement pour celles qui ont un ::before (contraire
+ à ce qu'affirmait le commentaire précédent, qui était FAUX — vérifié
+ par un test en conditions réelles). La bonne cible pour la mise en
+ ligne du badge est le ::before LUI-MÊME (inline-block, jamais son
+ parent) : le
garde son display natif, donc son marqueur, et le
+ badge d'un thème s'aligne quand même à côté du texte qui le suit
+ immédiatement dans le flux normal. */
+.docList li::before {
+ display: inline-block;
+ vertical-align: middle;
+ margin-right: 10px;
+}
+
.docList li + li {
- margin-top: 4px;
+ margin-top: var(--doc-item-spacing, 4px);
+}
+
+.docList li::marker {
+ color: var(--doc-marker-color, inherit);
+ font-size: var(--doc-marker-size, inherit);
}
.docMinigame {
diff --git a/static/document/js/document-editor.js b/static/document/js/document-editor.js
index a9bcb418..66ba3b72 100644
--- a/static/document/js/document-editor.js
+++ b/static/document/js/document-editor.js
@@ -1966,6 +1966,43 @@ const FORGE_DOC_FONT_FAMILY_OPTIONS = [
['"Courier New", monospace', 'Courier New (machine à écrire)'],
];
+// Retour utilisateur du 26/09/2026 : "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
+// simple sous-titre visuel (bordure du dessus), posé entre les champs
+// de CONTENU (propres à chaque kind) et les champs de CONTENEUR
+// (position/boîte, partagés via forgeDocRenderBoxFieldsHtml), dans
+// CHAQUE panneau de propriétés.
+function forgeDocPropsSectionHtml(label) {
+ return `
${label}
`;
+}
+
+const FORGE_DOC_CONTENT_ALIGN_OPTIONS = [['top', 'Haut'], ['center', 'Centré'], ['bottom', 'Bas']];
+
+function forgeDocRenderContentAlignHtml(a, idPrefix) {
+ // Retour utilisateur du 26/09/2026 : "je peux augmenter la hauteur
+ // d'un conteneur mais pas l'alignement vertical à l'intérieur" — sans
+ // effet tant que la hauteur du bloc correspond à son contenu, voir
+ // render_content_align (document_engine/rendering/box_style.py).
+ // Jamais posé pour le Bouton (rangée icône+texte, l'axe vertical y
+ // est déjà géré par align-items, voir document-editor.css).
+ const align = a.content_align || 'top';
+ return `
+
';
- return;
- }
- list.innerHTML = themes.map((t) => `
+ // "Aucun modèle" (retour utilisateur du 26/09/2026 : la modale doit
+ // proposer de "revenir à un document de base") — une carte TOUJOURS
+ // présente, même si le catalogue est vide, avec un id sentinelle vide
+ // ("" est le themeId qu'attend /document//theme/apply pour
+ // retirer le thème actuel, voir routes/document/document_theme_apply.py) —
+ // JAMAIS `null`, qui reste réservé à "rien n'a encore été cliqué"
+ // (voir forgeDocApplyTemplate, forgeDocOpenTemplateModal).
+ const noneCard = `
+
+ `;
+ const themeCards = themes.map((t) => `
`).join('');
+ list.innerHTML = noneCard + themeCards;
list.querySelectorAll('.docTemplateCard').forEach((btn) => {
btn.addEventListener('click', () => forgeDocSelectTemplate(btn.dataset.themeId));
});
@@ -3656,20 +3809,49 @@ function forgeDocSelectTemplate(themeId) {
const frame = document.getElementById('docTemplatePreviewFrame');
const empty = document.getElementById('docTemplatePreviewEmpty');
const actions = document.getElementById('docTemplatePreviewActions');
+ const keepBtn = document.getElementById('docTemplateKeepContentBtn');
+ const useBtn = document.getElementById('docTemplateUseContentBtn');
+ const removeBtn = document.getElementById('docTemplateRemoveBtn');
+ actions.hidden = false;
+ if (themeId === '') {
+ // "Aucun modèle" : rien à prévisualiser (aucun contenu de
+ // démonstration n'existe pour "pas de thème"), une seule action a
+ // du sens ici — retirer le thème actuel, jamais les deux boutons
+ // "contenu actuel/du modèle" qui supposent un vrai thème choisi.
+ frame.classList.remove('is-visible');
+ frame.src = 'about:blank';
+ empty.hidden = false;
+ empty.textContent = 'Revient à la mise en forme par défaut, sans modèle. Le contenu actuel du document n\'est jamais supprimé.';
+ keepBtn.hidden = true;
+ useBtn.hidden = true;
+ removeBtn.hidden = false;
+ return;
+ }
+ keepBtn.hidden = false;
+ useBtn.hidden = false;
+ removeBtn.hidden = true;
frame.src = `/document/${window.forgeDocState.slug}/theme/${themeId}/preview`;
frame.classList.add('is-visible');
empty.hidden = true;
- actions.hidden = false;
}
function forgeDocApplyTemplate(mode) {
- if (!forgeDocSelectedTemplateId) return;
+ // "" (Aucun modèle) est un choix explicite valide, jamais bloqué ici —
+ // seul `null` ("rien n'a encore été cliqué dans la modale") doit
+ // empêcher l'appel.
+ if (forgeDocSelectedTemplateId === null) return;
if (mode === 'template_content') {
const confirmed = window.confirm( // eslint-disable-line no-alert
'Remplacer tout le contenu actuel du document par le contenu de démonstration de ce modèle ? Cette action est irréversible.',
);
if (!confirmed) return;
}
+ if (forgeDocSelectedTemplateId === '') {
+ const confirmed = window.confirm( // eslint-disable-line no-alert
+ 'Retirer le modèle actuel ? Le document revient à sa mise en forme par défaut (le contenu, lui, n\'est jamais supprimé).',
+ );
+ if (!confirmed) return;
+ }
fetch(`/document/${window.forgeDocState.slug}/theme/apply`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -3699,6 +3881,7 @@ function forgeDocOpenTemplateModal() {
frame.classList.remove('is-visible');
frame.src = 'about:blank';
empty.hidden = false;
+ empty.textContent = 'Sélectionnez un modèle pour le visualiser.';
actions.hidden = true;
}
@@ -3715,6 +3898,7 @@ function forgeDocBindTemplateModal() {
});
document.getElementById('docTemplateKeepContentBtn').addEventListener('click', () => forgeDocApplyTemplate('keep_content'));
document.getElementById('docTemplateUseContentBtn').addEventListener('click', () => forgeDocApplyTemplate('template_content'));
+ document.getElementById('docTemplateRemoveBtn').addEventListener('click', () => forgeDocApplyTemplate('keep_content'));
document.addEventListener('keydown', (e) => {
if (e.key !== 'Escape') return;
if (document.getElementById('docTemplateModal').classList.contains('is-open')) forgeDocCloseTemplateModal();
diff --git a/static/document/themes/securite-incendie.css b/static/document/themes/securite-incendie.css
index 57cfc176..fe4c16a4 100644
--- a/static/document/themes/securite-incendie.css
+++ b/static/document/themes/securite-incendie.css
@@ -96,11 +96,29 @@
height: 13px;
}
-/* ---- Liste à puces / numérotée : coche sécurité / pastille panneau ---- */
+/* ---- 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
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);
@@ -110,7 +128,6 @@
.docList[data-kind="liste_puces"] li::before {
content: "";
- flex-shrink: 0;
width: 16px;
height: 16px;
margin-top: 1px;
@@ -126,10 +143,17 @@
.docList[data-kind="liste_numerotee"] li::before {
counter-increment: doc-theme-step;
content: counter(doc-theme-step);
- flex-shrink: 0;
width: 22px;
height: 22px;
- display: flex;
+
+ /* 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%;
diff --git a/templates/document/document_edit.html b/templates/document/document_edit.html
index aa367f34..b8e6a383 100644
--- a/templates/document/document_edit.html
+++ b/templates/document/document_edit.html
@@ -277,6 +277,7 @@
hidden>
+
diff --git a/tests/document/test_box_style.py b/tests/document/test_box_style.py
index 9ab53e16..5d354a3e 100644
--- a/tests/document/test_box_style.py
+++ b/tests/document/test_box_style.py
@@ -3,7 +3,7 @@ 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
+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:
@@ -76,3 +76,21 @@ def test_render_box_style_escapes_malicious_values() -> None:
border["top"] = {"style": "solid", "width": "1px", "color": '">'}
style = render_box_style({"padding": '">', "border": border})
assert "