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>
This commit is contained in:
william
2026-09-26 15:13:14 +02:00
co-authored by Claude Sonnet 5
parent 8f879f6321
commit 4db1269348
17 changed files with 1080 additions and 34 deletions
+27
View File
@@ -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 ""
@@ -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'<div class="docText" data-element-id="{el["id"]}" data-kind="{el["kind"]}" style="{style}">{content}</div>'
@@ -199,7 +203,15 @@ def _render_image(el: dict[str, Any], _children_by_parent: dict[int | None, list
media = f'<div class="docImageLightboxTrigger"{wrapper_style_attr}>{media}</div>'
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'<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>'
@@ -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 <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
@@ -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"<li>{html_lib.escape(str(item))}</li>" 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")
+58 -3
View File
@@ -113,6 +113,41 @@ regroupement à chaque appel.
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
@@ -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`