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>
This commit is contained in:
william
2026-09-26 09:25:30 +02:00
co-authored by Claude Sonnet 5
parent e03bea39c5
commit 6c7675fad0
10 changed files with 942 additions and 25 deletions
+108
View File
@@ -0,0 +1,108 @@
# 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).
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 — à faire (prochain élément)
### 3. Bouton — à faire
### 4. Liste à puces / numérotée — à faire
### 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.
+25 -2
View File
@@ -5,6 +5,7 @@ défaut posés à la création de chaque type."""
from typing import Any from typing import Any
from ..rendering.box_style import BOX_DEFAULTS, default_border
from .association_config import DEFAULT_ASSOCIATION_CONFIG from .association_config import DEFAULT_ASSOCIATION_CONFIG
from .memory_config import DEFAULT_MEMORY_CONFIG from .memory_config import DEFAULT_MEMORY_CONFIG
from .mots_config import DEFAULT_MOTS_CONFIG from .mots_config import DEFAULT_MOTS_CONFIG
@@ -44,12 +45,26 @@ _TEXT_DEFAULTS = {
"bold": False, "bold": False,
"italic": False, "italic": False,
"underline": False, "underline": False,
"strikethrough": False,
"align": "left", "align": "left",
"color": "var(--forge-text)", "color": "var(--forge-text)",
# Vide par défaut = pleine largeur de .docPageContent (comportement # Vide par défaut = pleine largeur de .docPageContent (comportement
# historique) ; une valeur CSS libre (ex. "60ch", "480px") restreint # historique) ; une valeur CSS libre (ex. "60ch", "480px") restreint
# la largeur du bloc — voir render_document_element._render_text. # la largeur du bloc — voir render_document_element._render_text.
"max_width": "", "max_width": "",
# 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 (padding/margin/
# background_color/border_radius/align_self) — voir rendering/box_style.py.
**BOX_DEFAULTS,
} }
@@ -58,9 +73,17 @@ 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 docs/plan/PLAN.md §3.3/§3.4/§3.5 pour la liste des propriétés
éditables par panneau, ici juste leur valeur de départ.""" éditables par panneau, ici juste leur valeur de départ."""
if kind == "titre": if kind == "titre":
return {"content": "Nouveau titre", "style": "titre1", **_TEXT_DEFAULTS} # "border" copié à chaque appel (jamais un dict partagé/muté par
# référence entre plusieurs éléments — même raison que la copie
# de "questions" pour le quiz plus bas).
return {"content": "Nouveau titre", "style": "titre1", **_TEXT_DEFAULTS, "border": default_border()}
if kind == "paragraphe": if kind == "paragraphe":
return {"content": "Nouveau paragraphe de texte.", "style": "paragraphe", **_TEXT_DEFAULTS} return {
"content": "Nouveau paragraphe de texte.",
"style": "paragraphe",
**_TEXT_DEFAULTS,
"border": default_border(),
}
if kind == "image": if kind == "image":
# svg_markup (optionnel) prend le pas sur src au rendu (voir # svg_markup (optionnel) prend le pas sur src au rendu (voir
# render_document_element._render_image) — un contenu vectoriel # render_document_element._render_image) — un contenu vectoriel
+9 -4
View File
@@ -45,10 +45,15 @@ l'affichage/debug hors bibliothèque).
Attributs posés à la création d'un élément de ce type (voir Attributs posés à la création d'un élément de ce type (voir
`document_engine/elements/add_document_element.py`). `document_engine/elements/add_document_element.py`).
- **Retour** : un dict d'attributs par défaut, dépendant du `kind` : - **Retour** : un dict d'attributs par défaut, dépendant du `kind` :
texte (`content/style` + `bold/italic/underline/align/color/max_width` — texte (`content/style` + `bold/italic/underline/strikethrough/align/
`max_width` vide par défaut = pleine largeur de `.docPageContent`, color/max_width/font_family/font_size/line_height/letter_spacing/
une valeur CSS libre ex. `"60ch"`/`"480px"` restreint le bloc, voir text_transform/text_shadow` + les attributs de boîte partagés
`rendering.md`), `padding/margin/background_color/border_radius/height/min_height/
max_height/min_width/box_shadow/opacity/align_self/border`, voir
`rendering/box_style.py` — tous vides, `False` ou `"none"`/`"stretch"`
par défaut = comportement historique inchangé ; `max_width`
restreint la largeur du bloc, une valeur CSS libre ex. `"60ch"`/
`"480px"`, voir `rendering.md`),
image (`src/alt/svg_markup` — `svg_markup` prend le pas sur `src` au image (`src/alt/svg_markup` — `svg_markup` prend le pas sur `src` au
rendu, voir rendering.md), bouton (`label/target/ rendu, voir rendering.md), bouton (`label/target/
attachment_stored_name/attachment_filename` — la pièce jointe est attachment_stored_name/attachment_filename` — la pièce jointe est
+109
View File
@@ -0,0 +1,109 @@
"""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",
"height": "",
"min_height": "",
"max_height": "",
"min_width": "",
"box_shadow": "",
"opacity": "",
}
def render_box_style(a: dict[str, Any]) -> str:
"""Construit les déclarations CSS inline communes à plusieurs kinds à
partir des attributs `padding`/`margin`/`background_color`/
`border_radius`/`height`/`min_height`/`max_height`/`min_width`/
`box_shadow`/`opacity`/`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"}` — un côté à
`style="none"` (ou absent) ne produit aucune déclaration pour ce
côté, jamais un `border-top:none` explicite.
- **Retour** : les déclarations CSS (`"propriete:valeur; ..."`),
jamais vide ni `None`.
- **Exceptions** : aucune."""
parts: list[str] = []
padding = str(a.get("padding", "")).strip()
if padding:
parts.append(f"padding:{html_lib.escape(padding)};")
margin = str(a.get("margin", "")).strip()
if margin:
parts.append(f"margin:{html_lib.escape(margin)};")
background_color = str(a.get("background_color", "")).strip()
if background_color:
parts.append(f"background-color:{html_lib.escape(background_color)};")
border_radius = str(a.get("border_radius", "")).strip()
if border_radius:
parts.append(f"border-radius:{html_lib.escape(border_radius)};")
height = str(a.get("height", "")).strip()
if height:
parts.append(f"height:{html_lib.escape(height)};")
min_height = str(a.get("min_height", "")).strip()
if min_height:
parts.append(f"min-height:{html_lib.escape(min_height)};")
max_height = str(a.get("max_height", "")).strip()
if max_height:
parts.append(f"max-height:{html_lib.escape(max_height)};")
min_width = str(a.get("min_width", "")).strip()
if min_width:
parts.append(f"min-width:{html_lib.escape(min_width)};")
box_shadow = str(a.get("box_shadow", "")).strip()
if box_shadow:
parts.append(f"box-shadow:{html_lib.escape(box_shadow)};")
opacity = str(a.get("opacity", "")).strip()
if opacity:
parts.append(f"opacity:{html_lib.escape(opacity)};")
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};")
# 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)
@@ -3,6 +3,7 @@ import json
import random import random
from typing import Any from typing import Any
from .box_style import render_box_style
from .sanitize_svg_markup import sanitize_svg_markup from .sanitize_svg_markup import sanitize_svg_markup
@@ -51,16 +52,48 @@ def _render_text(el: dict[str, Any], _children_by_parent: dict[int | None, list[
a = el["attributes"] a = el["attributes"]
content = html_lib.escape(str(a.get("content", ""))) content = html_lib.escape(str(a.get("content", "")))
style_name = a.get("style", "paragraphe") style_name = a.get("style", "paragraphe")
font_size, base_weight, line_height = _STYLE_PRESETS.get(style_name, _STYLE_PRESETS["paragraphe"]) preset_font_size, base_weight, preset_line_height = _STYLE_PRESETS.get(style_name, _STYLE_PRESETS["paragraphe"])
weight = 800 if a.get("bold") else base_weight weight = 800 if a.get("bold") else base_weight
font_style = "italic" if a.get("italic") else "normal" font_style = "italic" if a.get("italic") else "normal"
text_decoration = "underline" if a.get("underline") else "none"
# underline/strikethrough se combinent (text-decoration-line accepte
# plusieurs valeurs) — retour utilisateur du 26/09/2026 : "barré"
# manquait à côté du souligné déjà existant.
decoration_parts = []
if a.get("underline"):
decoration_parts.append("underline")
if a.get("strikethrough"):
decoration_parts.append("line-through")
text_decoration = " ".join(decoration_parts) if decoration_parts else "none"
align = html_lib.escape(str(a.get("align", "left"))) align = html_lib.escape(str(a.get("align", "left")))
color = html_lib.escape(str(a.get("color", "var(--forge-text)"))) color = html_lib.escape(str(a.get("color", "var(--forge-text)")))
# font_size/line_height : vides par défaut = valeurs du préréglage
# `style` (titre1/titre2/paragraphe/légende) inchangées ; une valeur
# explicite les remplace SANS changer `weight` (qui reste piloté par
# le préréglage + `bold`).
font_size = html_lib.escape(str(a.get("font_size", "")).strip()) or preset_font_size
line_height = html_lib.escape(str(a.get("line_height", "")).strip()) or str(preset_line_height)
style = ( style = (
f"font-size:{font_size}; font-weight:{weight}; line-height:{line_height}; " f"font-size:{font_size}; font-weight:{weight}; line-height:{line_height}; "
f"font-style:{font_style}; text-decoration:{text_decoration}; text-align:{align}; color:{color};" f"font-style:{font_style}; text-decoration:{text_decoration}; text-align:{align}; color:{color};"
) )
text_transform = str(a.get("text_transform", "none"))
if text_transform and text_transform != "none":
style += f" text-transform:{html_lib.escape(text_transform)};"
font_family = str(a.get("font_family", "")).strip()
if font_family:
style += f" font-family:{html_lib.escape(font_family)};"
letter_spacing = str(a.get("letter_spacing", "")).strip()
if letter_spacing:
style += f" letter-spacing:{html_lib.escape(letter_spacing)};"
text_shadow = str(a.get("text_shadow", "")).strip()
if text_shadow:
style += f" text-shadow:{html_lib.escape(text_shadow)};"
# max_width optionnel (ex. "60ch", "480px") — vide par défaut (pleine # max_width optionnel (ex. "60ch", "480px") — vide par défaut (pleine
# largeur de .docPageContent, comportement inchangé). Retour # largeur de .docPageContent, comportement inchangé). Retour
# utilisateur du 24/09/2026 : un paragraphe doit pouvoir rester plus # utilisateur du 24/09/2026 : un paragraphe doit pouvoir rester plus
@@ -69,6 +102,11 @@ def _render_text(el: dict[str, Any], _children_by_parent: dict[int | None, list[
max_width = str(a.get("max_width", "")).strip() max_width = str(a.get("max_width", "")).strip()
if max_width: if max_width:
style += f" max-width:{html_lib.escape(max_width)};" style += f" max-width:{html_lib.escape(max_width)};"
box_style = render_box_style(a)
if box_style:
style += f" {box_style}"
return f'<div class="docText" data-element-id="{el["id"]}" data-kind="{el["kind"]}" style="{style}">{content}</div>' return f'<div class="docText" data-element-id="{el["id"]}" data-kind="{el["kind"]}" style="{style}">{content}</div>'
+59 -6
View File
@@ -29,12 +29,24 @@ regroupement à chaque appel.
`justify-content` réels depuis `attributes`), enfants rendus `justify-content` réels depuis `attributes`), enfants rendus
récursivement. récursivement.
- **Texte** (`titre`/`paragraphe`) : `<div>` stylé selon `style` (préréglage - **Texte** (`titre`/`paragraphe`) : `<div>` stylé selon `style` (préréglage
taille/graisse/interligne) et `bold`/`italic`/`underline`/`align`/`color`. taille/graisse/interligne) et `bold`/`italic`/`underline`/`strikethrough`/
`max_width` (optionnel, ex. `"60ch"`, `"480px"`) ajoute `max-width` au `align`/`color`. `underline`/`strikethrough` se combinent dans un seul
style inline quand non vide — pleine largeur de `.docPageContent` par `text-decoration` (`"underline line-through"` si les deux sont actifs).
défaut, retour utilisateur du 24/09/2026 (un paragraphe doit pouvoir `font_size`/`line_height` (vides par défaut) remplacent les valeurs du
rester plus étroit que la page, sans dépendre d'une rangée qui en préréglage `style` SANS toucher `font-weight` (toujours piloté par le
partagerait la largeur avec un frère). 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 - **Image** : `<img>`, ou un bloc placeholder si `src` est vide — OU, si
`attributes["svg_markup"]` est non vide (prioritaire sur `src`), un `attributes["svg_markup"]` est non vide (prioritaire sur `src`), un
`<div>` portant directement ce fragment SVG nettoyé par `<div>` portant directement ce fragment SVG nettoyé par
@@ -180,6 +192,47 @@ regroupement à chaque appel.
emplacement réservé, formulaire de contenu dédié hors périmètre de emplacement réservé, formulaire de contenu dédié hors périmètre de
cette passe. 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", "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).
### `render_box_style(a: dict[str, Any]) -> str`
Construit les déclarations CSS inline pour `padding`/`margin`/
`background_color`/`border_radius`/`height`/`min_height`/`max_height`/
`min_width`/`box_shadow`/`opacity`/`border`/`align_self` de `a` — 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.
## `sanitize_svg_markup.py` — nettoyage du contenu SVG inline d'une image ## `sanitize_svg_markup.py` — nettoyage du contenu SVG inline d'une image
### `sanitize_svg_markup(markup: str) -> str` ### `sanitize_svg_markup(markup: str) -> str`
+127
View File
@@ -2436,3 +2436,130 @@ img.docImage {
border-bottom: 1px solid var(--doc-border); border-bottom: 1px solid var(--doc-border);
} }
} }
/* ---- Éditeur de bordure par côté (panneau Propriétés — voir
static/document/js/document-editor.js, forgeDocRenderBoxFieldsHtml) :
une ligne par côté (style/épaisseur/couleur), réutilisée par tous les
kinds au fil de l'audit du 26/09/2026. ---- */
.docBorderEditor {
display: flex;
flex-direction: column;
gap: 6px;
}
.docBorderSideRow {
display: grid;
grid-template-columns: 48px 1fr 56px 1fr;
gap: 6px;
align-items: center;
}
.docBorderSideLabel {
font-size: 12px;
color: var(--doc-muted);
}
.docBorderStyleSelect {
width: 100%;
min-width: 0;
font-size: 12px;
padding: 5px 6px;
border: 1px solid var(--doc-border);
border-radius: 6px;
background: var(--doc-bg-2);
color: var(--doc-text);
}
.docBorderWidthRange {
width: 100%;
min-width: 0;
}
.docBorderColorInput {
width: 100%;
min-width: 0;
height: 28px;
padding: 2px;
border: 1px solid var(--doc-border);
border-radius: 6px;
background: var(--doc-bg-2);
cursor: pointer;
}
/* ---- Curseurs génériques à case "activer" (voir static/document/js/
document-editor.js, forgeDocRenderToggleSliderHtml) — retour
utilisateur du 26/09/2026 : plus aucune valeur CSS saisie à la main,
uniquement des curseurs/cases/listes/sélecteurs de couleur. ---- */
.docSliderField {
margin-bottom: 10px;
}
.docSliderRow {
display: flex;
align-items: center;
gap: 8px;
}
/* `.docField input` (règle générique plus haut) pose padding/bordure/
fond/width:100% pensés pour un champ texte — remis à plat ici pour
une case à cocher/un curseur natifs, sinon ils héritent cette
apparence de champ texte (bug réel constaté le 26/09/2026, capture à
l'appui : case à cocher démesurée et centrée, curseur poussé hors
panneau). */
.docSliderRow input[type='checkbox'] {
flex-shrink: 0;
width: auto;
padding: 0;
border: none;
background: none;
cursor: pointer;
}
.docSliderRow input[type='range'] {
flex: 1;
min-width: 0;
width: auto;
padding: 0;
border: none;
background: none;
}
.docSliderRow input[type='range']:disabled {
opacity: 0.4;
}
.docSliderValue {
flex-shrink: 0;
min-width: 3em;
text-align: right;
font-size: 11.5px;
font-weight: 600;
color: var(--doc-muted);
font-variant-numeric: tabular-nums;
}
.docColorFieldRow {
display: flex;
align-items: center;
gap: 8px;
}
.docColorFieldRow input[type='color'] {
width: 40px;
height: 28px;
padding: 2px;
border: 1px solid var(--doc-border);
border-radius: 6px;
background: var(--doc-bg-2);
cursor: pointer;
}
#docPropsPanel input[type='color'] {
width: 40px;
height: 28px;
padding: 2px;
border: 1px solid var(--doc-border);
border-radius: 6px;
background: var(--doc-bg-2);
cursor: pointer;
}
+263 -11
View File
@@ -282,6 +282,17 @@ function forgeDocUpdateAttributes(id, newAttributes) {
forgeDocBindCanvasInteractions(); forgeDocBindCanvasInteractions();
forgeDocApplySelectionClass(); forgeDocApplySelectionClass();
} }
// Bug réel constaté le 26/09/2026 : les boutons (gras/alignement/
// segmentés...) ne reflétaient jamais leur nouvel état tant que la
// page n'était pas rechargée — leur classe "is-active" était figée
// au moment du rendu initial du panneau, jamais réévaluée après un
// clic. Point d'entrée UNIQUE de toute mise à jour d'attributs :
// reconstruire le panneau ICI, une fois, couvre TOUS les panneaux
// (texte, image, bouton, mini-jeux...) sans dupliquer cet appel dans
// chacun de leurs boutons.
if (window.forgeDocState.selectedId === id) {
forgeDocRenderProps(el);
}
}); });
} }
@@ -1687,6 +1698,208 @@ function forgeDocRefreshRightPanelDefault() {
else forgeDocRenderProps(null); else forgeDocRenderProps(null);
} }
/* ---------------------------------------------------------------------
* Champs de "boîte" PARTAGÉS par plusieurs kinds (padding/margin/couleur
* de fond/arrondi/bordure par côté/position du bloc) — voir
* document_engine/rendering/box_style.py, même jeu d'attributs côté
* serveur. Un seul HTML + un seul binding réutilisés par chaque panneau
* de propriétés plutôt que dupliqués (retour utilisateur du 26/09/2026 :
* audit complet des réglages manquants, à couvrir élément par élément).
* ------------------------------------------------------------------- */
const FORGE_DOC_BORDER_SIDES = [
['top', 'Haut'], ['right', 'Droite'], ['bottom', 'Bas'], ['left', 'Gauche'],
];
const FORGE_DOC_BORDER_STYLES = [
['none', 'Aucune'], ['solid', 'Solide'], ['dashed', 'Tirets'], ['dotted', 'Pointillés'], ['double', 'Double'],
];
const FORGE_DOC_ALIGN_SELF_OPTIONS = [
['stretch', 'Pleine largeur'], ['flex-start', 'Gauche'], ['center', 'Centré'], ['flex-end', 'Droite'],
];
const FORGE_DOC_SHADOW_PRESETS = [
['', 'Aucune'], ['0 2px 6px rgba(0,0,0,.15)', 'Légère'], ['0 8px 24px rgba(0,0,0,.25)', 'Marquée'],
];
const FORGE_DOC_TEXT_SHADOW_PRESETS = [
['', 'Aucune'], ['1px 1px 2px rgba(0,0,0,.35)', 'Légère'], ['2px 2px 6px rgba(0,0,0,.5)', 'Marquée'],
];
const FORGE_DOC_FONT_FAMILY_OPTIONS = [
['', 'Police du thème'],
['system-ui, -apple-system, sans-serif', 'Système (sans-serif)'],
['Georgia, serif', 'Georgia (serif)'],
['"Times New Roman", serif', 'Times New Roman (serif)'],
['Arial, Helvetica, sans-serif', 'Arial'],
['Verdana, sans-serif', 'Verdana'],
['Tahoma, sans-serif', 'Tahoma'],
['"Trebuchet MS", sans-serif', 'Trebuchet MS'],
['"Courier New", monospace', 'Courier New (machine à écrire)'],
];
/* ---------------------------------------------------------------------
* Contrôles génériques réutilisables — retour utilisateur du 26/09/2026 :
* "jamais l'utilisateur ne doit rentrer une valeur brute à la main" —
* un curseur (avec une case "activer" puisqu'un <input type=range> ne
* peut pas représenter "aucune valeur") remplace tout champ de texte
* libre pour une valeur numérique optionnelle.
* ------------------------------------------------------------------- */
function forgeDocRenderToggleSliderHtml(idPrefix, label, rawValue, opts) {
const hasValue = rawValue !== undefined && rawValue !== null && rawValue !== '';
const numeric = hasValue ? Number.parseFloat(rawValue) : opts.fallback;
const displayUnit = opts.displayUnit !== undefined ? opts.displayUnit : opts.unit;
return `
<div class="docField docSliderField">
<span class="docFieldLabel">${label}</span>
<div class="docSliderRow">
<input type="checkbox" id="${idPrefix}Toggle" title="Activer" ${hasValue ? 'checked' : ''}>
<input type="range" id="${idPrefix}Range" min="${opts.min}" max="${opts.max}" step="${opts.step}" value="${numeric}" ${hasValue ? '' : 'disabled'}>
<span class="docSliderValue" id="${idPrefix}Value">${hasValue ? numeric + displayUnit : '—'}</span>
</div>
</div>
`;
}
function forgeDocBindToggleSlider(idPrefix, opts, onChange) {
const toggle = document.getElementById(`${idPrefix}Toggle`);
const range = document.getElementById(`${idPrefix}Range`);
const valueLabel = document.getElementById(`${idPrefix}Value`);
const displayUnit = opts.displayUnit !== undefined ? opts.displayUnit : opts.unit;
const toAttr = opts.toAttr || ((n) => `${n}${opts.unit}`);
range.addEventListener('input', () => {
valueLabel.textContent = range.value + displayUnit;
});
range.addEventListener('change', () => {
if (toggle.checked) onChange(toAttr(Number.parseFloat(range.value)));
});
toggle.addEventListener('change', () => {
range.disabled = !toggle.checked;
if (toggle.checked) {
valueLabel.textContent = range.value + displayUnit;
onChange(toAttr(Number.parseFloat(range.value)));
} else {
valueLabel.textContent = '—';
onChange('');
}
});
}
function forgeDocToHexColor(value, fallback) {
return /^#[0-9a-f]{6}$/i.test(value || '') ? value : fallback;
}
function forgeDocRenderBoxFieldsHtml(a, idPrefix) {
const border = a.border || {};
const alignSelf = a.align_self || 'stretch';
const opacityPercent = a.opacity !== '' && a.opacity !== undefined ? Math.round(Number.parseFloat(a.opacity) * 100) : '';
return `
${forgeDocRenderToggleSliderHtml(`${idPrefix}Padding`, 'Espacement intérieur (padding)', a.padding, {
min: 0, max: 80, step: 2, unit: 'px', fallback: 16,
})}
${forgeDocRenderToggleSliderHtml(`${idPrefix}Margin`, 'Espacement extérieur (margin)', a.margin, {
min: 0, max: 80, step: 2, unit: 'px', fallback: 16,
})}
<div class="docField">
<label for="${idPrefix}Bg">Couleur de fond</label>
<div class="docColorFieldRow">
<input type="color" id="${idPrefix}Bg" value="${forgeDocToHexColor(a.background_color, '#ffffff')}">
<button type="button" class="docBtnSecondary" id="${idPrefix}BgNone">Transparent</button>
</div>
</div>
${forgeDocRenderToggleSliderHtml(`${idPrefix}Radius`, 'Arrondi des bords', a.border_radius, {
min: 0, max: 100, step: 2, unit: 'px', fallback: 8,
})}
${forgeDocRenderToggleSliderHtml(`${idPrefix}Height`, 'Hauteur fixe', a.height, {
min: 0, max: 800, step: 10, unit: 'px', fallback: 200,
})}
${forgeDocRenderToggleSliderHtml(`${idPrefix}MinHeight`, 'Hauteur minimale', a.min_height, {
min: 0, max: 800, step: 10, unit: 'px', fallback: 200,
})}
${forgeDocRenderToggleSliderHtml(`${idPrefix}MaxHeight`, 'Hauteur maximale', a.max_height, {
min: 0, max: 800, step: 10, unit: 'px', fallback: 200,
})}
${forgeDocRenderToggleSliderHtml(`${idPrefix}MinWidth`, 'Largeur minimale', a.min_width, {
min: 0, max: 800, step: 10, unit: 'px', fallback: 200,
})}
<div class="docField">
<span class="docFieldLabel">Ombre portée du bloc</span>
<div class="docSegmented" id="${idPrefix}ShadowSeg">
${FORGE_DOC_SHADOW_PRESETS.map(([v, l]) => `<button type="button" class="docSegBtn ${(a.box_shadow || '') === v ? 'is-active' : ''}" data-shadow="${forgeDocEscapeHtml(v)}">${l}</button>`).join('')}
</div>
</div>
${forgeDocRenderToggleSliderHtml(`${idPrefix}Opacity`, 'Opacité', opacityPercent, {
min: 0, max: 100, step: 5, unit: '%', fallback: 100,
})}
<div class="docField">
<span class="docFieldLabel">Position du bloc</span>
<div class="docSegmented" id="${idPrefix}AlignSelfSeg">
${FORGE_DOC_ALIGN_SELF_OPTIONS.map(([v, l]) => `<button type="button" class="docSegBtn ${alignSelf === v ? 'is-active' : ''}" data-align-self="${v}">${l}</button>`).join('')}
</div>
</div>
<div class="docField">
<span class="docFieldLabel">Bordure (par côté)</span>
<div class="docBorderEditor" id="${idPrefix}BorderEditor">
${FORGE_DOC_BORDER_SIDES.map(([side, label]) => {
const sideBorder = border[side] || {};
return `
<div class="docBorderSideRow" data-side="${side}">
<span class="docBorderSideLabel">${label}</span>
<select class="docBorderStyleSelect" data-side="${side}">
${FORGE_DOC_BORDER_STYLES.map(([v, l]) => `<option value="${v}" ${(sideBorder.style || 'none') === v ? 'selected' : ''}>${l}</option>`).join('')}
</select>
<input type="range" class="docBorderWidthRange" data-side="${side}" min="1" max="10" step="1" value="${Number.parseFloat(sideBorder.width) || 1}" title="Épaisseur">
<input type="color" class="docBorderColorInput" data-side="${side}" value="${forgeDocToHexColor(sideBorder.color, '#c9c4b8')}" title="Couleur">
</div>`;
}).join('')}
</div>
</div>
`;
}
function forgeDocBindBoxFields(panel, el, idPrefix, patch) {
forgeDocBindToggleSlider(`${idPrefix}Padding`, { unit: 'px' }, (v) => patch({ padding: v }));
forgeDocBindToggleSlider(`${idPrefix}Margin`, { unit: 'px' }, (v) => patch({ margin: v }));
forgeDocBindToggleSlider(`${idPrefix}Radius`, { unit: 'px' }, (v) => patch({ border_radius: v }));
forgeDocBindToggleSlider(`${idPrefix}Height`, { unit: 'px' }, (v) => patch({ height: v }));
forgeDocBindToggleSlider(`${idPrefix}MinHeight`, { unit: 'px' }, (v) => patch({ min_height: v }));
forgeDocBindToggleSlider(`${idPrefix}MaxHeight`, { unit: 'px' }, (v) => patch({ max_height: v }));
forgeDocBindToggleSlider(`${idPrefix}MinWidth`, { unit: 'px' }, (v) => patch({ min_width: v }));
// Toujours une vraie valeur numérique quand la case est cochée (jamais
// "" pour 100%, même si ça revient visuellement au même) : sinon l'état
// "activé" de la case ne survivrait pas à un rechargement du panneau
// (hasValue redeviendrait faux, la case semblerait décochée à tort).
forgeDocBindToggleSlider(`${idPrefix}Opacity`, { unit: '%', toAttr: (n) => String(n / 100) }, (v) => patch({ opacity: v }));
document.getElementById(`${idPrefix}Bg`).addEventListener('change', (e) => patch({ background_color: e.target.value }));
document.getElementById(`${idPrefix}BgNone`).addEventListener('click', () => patch({ background_color: '' }));
panel.querySelectorAll(`#${idPrefix}ShadowSeg .docSegBtn`).forEach((btn) => {
btn.addEventListener('click', () => patch({ box_shadow: btn.dataset.shadow }));
});
panel.querySelectorAll(`#${idPrefix}AlignSelfSeg .docSegBtn`).forEach((btn) => {
btn.addEventListener('click', () => patch({ align_self: btn.dataset.alignSelf }));
});
function patchBorderSide(side, partial) {
// Toujours relu depuis el.attributes AU MOMENT de l'appel (jamais un
// instantané capturé au moment du binding) : el.attributes est
// réassigné en place par forgeDocUpdateAttributes après chaque
// modification — un instantané figé ferait perdre le changement
// précédent dès qu'on modifie un 2e côté/champ.
const currentBorder = { ...el.attributes.border };
const nextSide = { ...currentBorder[side], ...partial };
patch({ border: { ...currentBorder, [side]: nextSide } });
}
panel.querySelectorAll(`#${idPrefix}BorderEditor .docBorderStyleSelect`).forEach((sel) => {
sel.addEventListener('change', (e) => patchBorderSide(sel.dataset.side, { style: e.target.value }));
});
panel.querySelectorAll(`#${idPrefix}BorderEditor .docBorderWidthRange`).forEach((range) => {
range.addEventListener('change', (e) => patchBorderSide(range.dataset.side, { width: `${e.target.value}px` }));
});
panel.querySelectorAll(`#${idPrefix}BorderEditor .docBorderColorInput`).forEach((inp) => {
inp.addEventListener('change', (e) => patchBorderSide(inp.dataset.side, { color: e.target.value }));
});
}
function forgeDocRenderTextProps(panel, el) { function forgeDocRenderTextProps(panel, el) {
const a = el.attributes; const a = el.attributes;
panel.innerHTML = ` panel.innerHTML = `
@@ -1704,42 +1917,81 @@ function forgeDocRenderTextProps(panel, el) {
<button type="button" class="docFmtBtn ${a.bold ? 'is-active' : ''}" id="docBoldBtn" title="Gras">B</button> <button type="button" class="docFmtBtn ${a.bold ? 'is-active' : ''}" id="docBoldBtn" title="Gras">B</button>
<button type="button" class="docFmtBtn docFmtBtn--italic ${a.italic ? 'is-active' : ''}" id="docItalicBtn" title="Italique">I</button> <button type="button" class="docFmtBtn docFmtBtn--italic ${a.italic ? 'is-active' : ''}" id="docItalicBtn" title="Italique">I</button>
<button type="button" class="docFmtBtn docFmtBtn--underline ${a.underline ? 'is-active' : ''}" id="docUnderlineBtn" title="Souligné">U</button> <button type="button" class="docFmtBtn docFmtBtn--underline ${a.underline ? 'is-active' : ''}" id="docUnderlineBtn" title="Souligné">U</button>
<button type="button" class="docFmtBtn ${a.strikethrough ? 'is-active' : ''}" id="docStrikeBtn" title="Barré"><s>S</s></button>
</div> </div>
</div> </div>
<div class="docField"> <div class="docField">
<span class="docFieldLabel">Alignement</span> <span class="docFieldLabel">Alignement du texte</span>
<div class="docToolbarRow" id="docAlignRow"> <div class="docToolbarRow" id="docAlignRow">
<button type="button" class="docFmtBtn ${a.align === 'left' ? 'is-active' : ''}" data-align="left">◧</button> <button type="button" class="docFmtBtn ${a.align === 'left' ? 'is-active' : ''}" data-align="left" title="Aligné à gauche">◧</button>
<button type="button" class="docFmtBtn ${a.align === 'center' ? 'is-active' : ''}" data-align="center">▣</button> <button type="button" class="docFmtBtn ${a.align === 'center' ? 'is-active' : ''}" data-align="center" title="Centré">▣</button>
<button type="button" class="docFmtBtn ${a.align === 'right' ? 'is-active' : ''}" data-align="right">◨</button> <button type="button" class="docFmtBtn ${a.align === 'right' ? 'is-active' : ''}" data-align="right" title="Aligné à droite">◨</button>
</div> </div>
</div> </div>
<div class="docField"> <div class="docField">
<span class="docFieldLabel">Couleur du texte</span> <span class="docFieldLabel">Majuscules / minuscules</span>
<div class="docSwatches" id="docColorSwatches"> <div class="docSegmented" id="docTextTransformSeg">
${['var(--doc-text)', 'var(--doc-muted)', '#ff5f2e', '#ffb020'].map((c) => `<div class="docSwatch ${a.color === c ? 'is-active' : ''}" data-color="${c}" style="background:${c.replace('doc-', 'forge-').replace('var(--forge-text)', 'var(--forge-text)')};"></div>`).join('')} ${[['none', 'Normal'], ['uppercase', 'MAJUSCULES'], ['lowercase', 'minuscules'], ['capitalize', 'Capitales']].map(([v, l]) => `<button type="button" class="docSegBtn ${(a.text_transform || 'none') === v ? 'is-active' : ''}" data-transform="${v}">${l}</button>`).join('')}
</div> </div>
</div> </div>
<div class="docField"><label for="docMaxWidthInput">Largeur maximale (optionnel, ex. 60ch, 480px)</label><input type="text" id="docMaxWidthInput" value="${a.max_width || ''}" placeholder="pleine largeur"></div> <div class="docField">
<label for="docColorInput">Couleur du texte</label>
<input type="color" id="docColorInput" value="${forgeDocToHexColor(a.color, '#14161c')}">
</div>
<div class="docField">
<label for="docFontFamilyInput">Police de caractère</label>
<select id="docFontFamilyInput">
${FORGE_DOC_FONT_FAMILY_OPTIONS.map(([v, l]) => `<option value="${forgeDocEscapeHtml(v)}" ${(a.font_family || '') === v ? 'selected' : ''}>${l}</option>`).join('')}
</select>
</div>
${forgeDocRenderToggleSliderHtml('docFontSize', 'Taille de police', a.font_size, {
min: 10, max: 72, step: 1, unit: 'px', fallback: 16,
})}
${forgeDocRenderToggleSliderHtml('docLineHeight', 'Hauteur de ligne', a.line_height, {
min: 1, max: 2.5, step: 0.1, unit: '', fallback: 1.5,
})}
${forgeDocRenderToggleSliderHtml('docLetterSpacing', 'Espacement des lettres', a.letter_spacing, {
min: -2, max: 10, step: 0.5, unit: 'px', fallback: 0,
})}
<div class="docField">
<span class="docFieldLabel">Ombre du texte</span>
<div class="docSegmented" id="docTextShadowSeg">
${FORGE_DOC_TEXT_SHADOW_PRESETS.map(([v, l]) => `<button type="button" class="docSegBtn ${(a.text_shadow || '') === v ? 'is-active' : ''}" data-shadow="${forgeDocEscapeHtml(v)}">${l}</button>`).join('')}
</div>
</div>
${forgeDocRenderToggleSliderHtml('docMaxWidth', 'Largeur maximale', a.max_width, {
min: 200, max: 1200, step: 20, unit: 'px', fallback: 600,
})}
${forgeDocRenderBoxFieldsHtml(a, 'docText')}
${forgeDocDeleteButtonHtml()} ${forgeDocDeleteButtonHtml()}
`; `;
function patch(partial) { forgeDocUpdateAttributes(el.id, { ...el.attributes, ...partial }); } function patch(partial) { forgeDocUpdateAttributes(el.id, { ...el.attributes, ...partial }); }
document.getElementById('docContentInput').addEventListener('change', (e) => patch({ content: e.target.value })); document.getElementById('docContentInput').addEventListener('change', (e) => patch({ content: e.target.value }));
document.getElementById('docMaxWidthInput').addEventListener('change', (e) => patch({ max_width: e.target.value.trim() })); forgeDocBindToggleSlider('docMaxWidth', { unit: 'px' }, (v) => patch({ max_width: v }));
panel.querySelectorAll('#docStyleSeg .docSegBtn').forEach((btn) => { panel.querySelectorAll('#docStyleSeg .docSegBtn').forEach((btn) => {
btn.addEventListener('click', () => patch({ style: btn.dataset.style })); btn.addEventListener('click', () => patch({ style: btn.dataset.style }));
}); });
document.getElementById('docBoldBtn').addEventListener('click', () => patch({ bold: !a.bold })); document.getElementById('docBoldBtn').addEventListener('click', () => patch({ bold: !a.bold }));
document.getElementById('docItalicBtn').addEventListener('click', () => patch({ italic: !a.italic })); document.getElementById('docItalicBtn').addEventListener('click', () => patch({ italic: !a.italic }));
document.getElementById('docUnderlineBtn').addEventListener('click', () => patch({ underline: !a.underline })); document.getElementById('docUnderlineBtn').addEventListener('click', () => patch({ underline: !a.underline }));
document.getElementById('docStrikeBtn').addEventListener('click', () => patch({ strikethrough: !a.strikethrough }));
panel.querySelectorAll('#docAlignRow [data-align]').forEach((btn) => { panel.querySelectorAll('#docAlignRow [data-align]').forEach((btn) => {
btn.addEventListener('click', () => patch({ align: btn.dataset.align })); btn.addEventListener('click', () => patch({ align: btn.dataset.align }));
}); });
panel.querySelectorAll('#docColorSwatches .docSwatch').forEach((sw) => { panel.querySelectorAll('#docTextTransformSeg .docSegBtn').forEach((btn) => {
sw.addEventListener('click', () => patch({ color: sw.dataset.color })); btn.addEventListener('click', () => patch({ text_transform: btn.dataset.transform }));
}); });
document.getElementById('docColorInput').addEventListener('change', (e) => patch({ color: e.target.value }));
document.getElementById('docFontFamilyInput').addEventListener('change', (e) => patch({ font_family: e.target.value }));
forgeDocBindToggleSlider('docFontSize', { unit: 'px' }, (v) => patch({ font_size: v }));
forgeDocBindToggleSlider('docLineHeight', { unit: '' }, (v) => patch({ line_height: v }));
forgeDocBindToggleSlider('docLetterSpacing', { unit: 'px' }, (v) => patch({ letter_spacing: v }));
panel.querySelectorAll('#docTextShadowSeg .docSegBtn').forEach((btn) => {
btn.addEventListener('click', () => patch({ text_shadow: btn.dataset.shadow }));
});
forgeDocBindBoxFields(panel, el, 'docText', patch);
} }
function forgeDocRenderImageProps(panel, el) { function forgeDocRenderImageProps(panel, el) {
+78
View File
@@ -0,0 +1,78 @@
"""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
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
+124
View File
@@ -12,6 +12,7 @@ from typing import Any
import db import db
import document_engine as doc_engine import document_engine as doc_engine
from document_engine.rendering.box_style import default_border as doc_engine_box_default_border
def _new_support(tmp_support_slug_cleanup: Any, name: str = "Support de test") -> tuple[str, int]: def _new_support(tmp_support_slug_cleanup: Any, name: str = "Support de test") -> tuple[str, int]:
@@ -32,6 +33,129 @@ def test_add_and_get_document_element_uses_default_attributes(tmp_support_slug_c
assert element["attributes"]["content"] == "Nouveau titre" assert element["attributes"]["content"] == "Nouveau titre"
def test_titre_default_attributes_include_all_new_formatting_fields(tmp_support_slug_cleanup: Any) -> None:
"""Audit du 26/09/2026 (réglages manquants) — chaque nouveau champ
doit exister avec une valeur "neutre" par défaut (comportement
historique inchangé, voir _render_text)."""
slug, page_id = _new_support(tmp_support_slug_cleanup)
element_id = doc_engine.add_document_element(slug, "titre", page_id=page_id)
element = doc_engine.get_document_element(slug, element_id)
assert element is not None
attrs = element["attributes"]
assert attrs["strikethrough"] is False
assert attrs["font_family"] == ""
assert attrs["font_size"] == ""
assert attrs["line_height"] == ""
assert attrs["letter_spacing"] == ""
assert attrs["text_transform"] == "none"
assert attrs["text_shadow"] == ""
assert attrs["padding"] == ""
assert attrs["margin"] == ""
assert attrs["background_color"] == ""
assert attrs["border_radius"] == ""
assert attrs["align_self"] == "stretch"
assert attrs["height"] == ""
assert attrs["min_height"] == ""
assert attrs["max_height"] == ""
assert attrs["min_width"] == ""
assert attrs["box_shadow"] == ""
assert attrs["opacity"] == ""
assert attrs["border"] == doc_engine_box_default_border()
html = doc_engine.render_document([element])
assert html == (
f'<div class="docText" data-element-id="{element_id}" data-kind="titre" '
f'style="font-size:clamp(1.6rem,4vw,2rem); font-weight:800; line-height:1.15; '
f'font-style:normal; text-decoration:none; text-align:left; color:var(--forge-text);">'
f"Nouveau titre</div>"
)
def test_titre_strikethrough_combines_with_underline(tmp_support_slug_cleanup: Any) -> None:
slug, page_id = _new_support(tmp_support_slug_cleanup)
element_id = doc_engine.add_document_element(slug, "titre", page_id=page_id)
doc_engine.update_document_element_attributes(
slug, element_id, {"content": "X", "underline": True, "strikethrough": True}
)
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
assert "text-decoration:underline line-through;" in html
def test_titre_font_size_and_line_height_override_the_style_preset(tmp_support_slug_cleanup: Any) -> None:
slug, page_id = _new_support(tmp_support_slug_cleanup)
element_id = doc_engine.add_document_element(slug, "titre", page_id=page_id)
doc_engine.update_document_element_attributes(
slug, element_id, {"content": "X", "style": "titre1", "font_size": "40px", "line_height": "1"}
)
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
assert "font-size:40px;" in html
assert "line-height:1;" in html
assert "clamp(1.6rem" not in html
def test_titre_font_family_letter_spacing_and_text_shadow_render_when_set(tmp_support_slug_cleanup: Any) -> None:
slug, page_id = _new_support(tmp_support_slug_cleanup)
element_id = doc_engine.add_document_element(slug, "titre", page_id=page_id)
doc_engine.update_document_element_attributes(
slug,
element_id,
{
"content": "X",
"font_family": "Georgia, serif",
"letter_spacing": "0.05em",
"text_shadow": "1px 1px 2px rgba(0,0,0,.3)",
},
)
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
assert "font-family:Georgia, serif;" in html
assert "letter-spacing:0.05em;" in html
assert "text-shadow:1px 1px 2px rgba(0,0,0,.3);" in html
def test_titre_text_transform_uppercase_renders_and_none_does_not(tmp_support_slug_cleanup: Any) -> None:
slug, page_id = _new_support(tmp_support_slug_cleanup)
element_id = doc_engine.add_document_element(slug, "titre", page_id=page_id)
html_default = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
assert "text-transform" not in html_default
doc_engine.update_document_element_attributes(slug, element_id, {"content": "X", "text_transform": "uppercase"})
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
assert "text-transform:uppercase;" in html
def test_titre_box_style_attributes_render_via_shared_box_style(tmp_support_slug_cleanup: Any) -> None:
slug, page_id = _new_support(tmp_support_slug_cleanup)
element_id = doc_engine.add_document_element(slug, "titre", page_id=page_id)
border = doc_engine_box_default_border()
border["bottom"] = {"style": "dashed", "width": "3px", "color": "#123456"}
doc_engine.update_document_element_attributes(
slug,
element_id,
{
"content": "X",
"padding": "10px",
"margin": "5px",
"background_color": "#fafafa",
"border_radius": "4px",
"align_self": "center",
"border": border,
"height": "300px",
"box_shadow": "0 4px 12px rgba(0,0,0,.2)",
"opacity": "0.9",
},
)
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
assert "padding:10px;" in html
assert "margin:5px;" in html
assert "background-color:#fafafa;" in html
assert "border-radius:4px;" in html
assert "align-self:center;" in html
assert "border-bottom:3px dashed #123456;" in html
assert "height:300px;" in html
assert "box-shadow:0 4px 12px rgba(0,0,0,.2);" in html
assert "opacity:0.9;" in html
def test_get_document_element_returns_none_for_missing_id(tmp_support_slug_cleanup: Any) -> None: def test_get_document_element_returns_none_for_missing_id(tmp_support_slug_cleanup: Any) -> None:
slug, _page_id = _new_support(tmp_support_slug_cleanup) slug, _page_id = _new_support(tmp_support_slug_cleanup)
assert doc_engine.get_document_element(slug, 999) is None assert doc_engine.get_document_element(slug, 999) is None