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:
co-authored by
Claude Sonnet 5
parent
e03bea39c5
commit
6c7675fad0
@@ -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.
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>'
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user