From 6c7675fad0451ad9008cba4cdd2bc997ee6e2fe7 Mon Sep 17 00:00:00 2001 From: william Date: Sat, 26 Sep 2026 09:25:30 +0200 Subject: [PATCH] =?UTF-8?q?Audit=20complet=20de=20mise=20en=20forme=20?= =?UTF-8?q?=E2=80=94=20Titre/Paragraphe=20(1er=20=C3=A9l=C3=A9ment=20du=20?= =?UTF-8?q?tableau)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/plan/AUDIT_MISE_EN_FORME.md | 108 +++++++ document_engine/labels/element_kind_labels.py | 27 +- document_engine/labels/labels.md | 13 +- document_engine/rendering/box_style.py | 109 +++++++ .../rendering/render_document_element.py | 42 ++- document_engine/rendering/rendering.md | 65 ++++- static/document/document-editor.css | 127 ++++++++ static/document/js/document-editor.js | 274 +++++++++++++++++- tests/document/test_box_style.py | 78 +++++ tests/document/test_document_elements.py | 124 ++++++++ 10 files changed, 942 insertions(+), 25 deletions(-) create mode 100644 docs/plan/AUDIT_MISE_EN_FORME.md create mode 100644 document_engine/rendering/box_style.py create mode 100644 tests/document/test_box_style.py diff --git a/docs/plan/AUDIT_MISE_EN_FORME.md b/docs/plan/AUDIT_MISE_EN_FORME.md new file mode 100644 index 00000000..a4092b37 --- /dev/null +++ b/docs/plan/AUDIT_MISE_EN_FORME.md @@ -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 `` 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)
**Boîte** : padding, margin (y compris négatif/auto pour centrer), hauteur, min/max-height, min-width
**Bordure/ombre** : style (pointillé/tireté/double…), épaisseur, couleur, par côté, arrondi par coin, ombre portée du bloc (`box-shadow`)
**Fond** : couleur de fond, dégradé, image de fond, opacité du bloc
**Position/affichage** : position du bloc dans son conteneur (pas juste le texte dedans), `overflow`, `z-index`
**Effets** : transition/animation au survol, curseur, filtre (flou, contraste…)
**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`
**Bordure/ombre** : style/couleur/épaisseur/par côté, arrondi par coin (actuellement fixe à 10px, non réglable), ombre portée
**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
**Comportement** : lien cliquable, ouverture en plein écran/lightbox au clic, chargement différé (`loading=lazy`), légende (caption) affichée sous l'image
**Position** : alignement horizontal dans son conteneur, `margin`, `padding` autour
**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
**Boîte** : padding, margin, largeur/hauteur, alignement du bloc
**Bordure/ombre** : style/couleur/épaisseur/par côté, arrondi par coin, ombre portée
**Fond** : couleur, dégradé, image
**États interactifs** : styles distincts survol/actif/désactivé (aucune notion d'état n'existe)
**Icône** : aucune icône à côté du texte du bouton (contrairement au badge)
**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)
**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
**Boîte** : padding/margin globaux ET par élément de liste, espacement entre éléments réglable, indentation
**Bordure/fond** : par élément de liste ou sur la liste entière
**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
**Boîte** : hauteur, padding, margin, min/max-width
**Bordure** : style (actuellement toujours plein), couleur, épaisseur, **par côté** (demandé explicitement), arrondi **par coin** (actuellement un seul rayon pour les 4 coins)
**Fond** : couleur (actuellement figée par le thème), dégradé
**Ombre** : `box-shadow`
**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
**Bordure/fond/ombre** : rien
**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)
**Position** : `overflow`, `position` (sticky/absolute), `z-index`
**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. diff --git a/document_engine/labels/element_kind_labels.py b/document_engine/labels/element_kind_labels.py index 1bd47420..d9ade5ae 100644 --- a/document_engine/labels/element_kind_labels.py +++ b/document_engine/labels/element_kind_labels.py @@ -5,6 +5,7 @@ défaut posés à la création de chaque type.""" from typing import Any +from ..rendering.box_style import BOX_DEFAULTS, default_border from .association_config import DEFAULT_ASSOCIATION_CONFIG from .memory_config import DEFAULT_MEMORY_CONFIG from .mots_config import DEFAULT_MOTS_CONFIG @@ -44,12 +45,26 @@ _TEXT_DEFAULTS = { "bold": False, "italic": False, "underline": False, + "strikethrough": False, "align": "left", "color": "var(--forge-text)", # Vide par défaut = pleine largeur de .docPageContent (comportement # historique) ; une valeur CSS libre (ex. "60ch", "480px") restreint # la largeur du bloc — voir render_document_element._render_text. "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 éditables par panneau, ici juste leur valeur de départ.""" if kind == "titre": - return {"content": "Nouveau titre", "style": "titre1", **_TEXT_DEFAULTS} + # "border" copié à chaque appel (jamais un dict partagé/muté par + # référence entre plusieurs éléments — même raison que la copie + # de "questions" pour le quiz plus bas). + return {"content": "Nouveau titre", "style": "titre1", **_TEXT_DEFAULTS, "border": default_border()} if kind == "paragraphe": - return {"content": "Nouveau paragraphe de texte.", "style": "paragraphe", **_TEXT_DEFAULTS} + return { + "content": "Nouveau paragraphe de texte.", + "style": "paragraphe", + **_TEXT_DEFAULTS, + "border": default_border(), + } if kind == "image": # svg_markup (optionnel) prend le pas sur src au rendu (voir # render_document_element._render_image) — un contenu vectoriel diff --git a/document_engine/labels/labels.md b/document_engine/labels/labels.md index fb0c5520..ef6cc180 100644 --- a/document_engine/labels/labels.md +++ b/document_engine/labels/labels.md @@ -45,10 +45,15 @@ l'affichage/debug hors bibliothèque). Attributs posés à la création d'un élément de ce type (voir `document_engine/elements/add_document_element.py`). - **Retour** : un dict d'attributs par défaut, dépendant du `kind` : - texte (`content/style` + `bold/italic/underline/align/color/max_width` — - `max_width` vide par défaut = pleine largeur de `.docPageContent`, - une valeur CSS libre ex. `"60ch"`/`"480px"` restreint le bloc, voir - `rendering.md`), + texte (`content/style` + `bold/italic/underline/strikethrough/align/ + color/max_width/font_family/font_size/line_height/letter_spacing/ + text_transform/text_shadow` + les attributs de boîte partagés + `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 rendu, voir rendering.md), bouton (`label/target/ attachment_stored_name/attachment_filename` — la pièce jointe est diff --git a/document_engine/rendering/box_style.py b/document_engine/rendering/box_style.py new file mode 100644 index 00000000..8b11bb70 --- /dev/null +++ b/document_engine/rendering/box_style.py @@ -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) diff --git a/document_engine/rendering/render_document_element.py b/document_engine/rendering/render_document_element.py index 801695fc..66c4af60 100644 --- a/document_engine/rendering/render_document_element.py +++ b/document_engine/rendering/render_document_element.py @@ -3,6 +3,7 @@ import json import random from typing import Any +from .box_style import render_box_style 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"] content = html_lib.escape(str(a.get("content", ""))) style_name = a.get("style", "paragraphe") - font_size, base_weight, line_height = _STYLE_PRESETS.get(style_name, _STYLE_PRESETS["paragraphe"]) + preset_font_size, base_weight, preset_line_height = _STYLE_PRESETS.get(style_name, _STYLE_PRESETS["paragraphe"]) weight = 800 if a.get("bold") else base_weight font_style = "italic" if a.get("italic") else "normal" - text_decoration = "underline" if a.get("underline") else "none" + + # underline/strikethrough se combinent (text-decoration-line accepte + # plusieurs valeurs) — retour utilisateur du 26/09/2026 : "barré" + # manquait à côté du souligné déjà existant. + decoration_parts = [] + if a.get("underline"): + decoration_parts.append("underline") + if a.get("strikethrough"): + decoration_parts.append("line-through") + text_decoration = " ".join(decoration_parts) if decoration_parts else "none" + align = html_lib.escape(str(a.get("align", "left"))) color = html_lib.escape(str(a.get("color", "var(--forge-text)"))) + + # font_size/line_height : vides par défaut = valeurs du préréglage + # `style` (titre1/titre2/paragraphe/légende) inchangées ; une valeur + # explicite les remplace SANS changer `weight` (qui reste piloté par + # le préréglage + `bold`). + font_size = html_lib.escape(str(a.get("font_size", "")).strip()) or preset_font_size + line_height = html_lib.escape(str(a.get("line_height", "")).strip()) or str(preset_line_height) + style = ( f"font-size:{font_size}; font-weight:{weight}; line-height:{line_height}; " f"font-style:{font_style}; text-decoration:{text_decoration}; text-align:{align}; color:{color};" ) + + text_transform = str(a.get("text_transform", "none")) + if text_transform and text_transform != "none": + style += f" text-transform:{html_lib.escape(text_transform)};" + font_family = str(a.get("font_family", "")).strip() + if font_family: + style += f" font-family:{html_lib.escape(font_family)};" + letter_spacing = str(a.get("letter_spacing", "")).strip() + if letter_spacing: + style += f" letter-spacing:{html_lib.escape(letter_spacing)};" + text_shadow = str(a.get("text_shadow", "")).strip() + if text_shadow: + style += f" text-shadow:{html_lib.escape(text_shadow)};" + # max_width optionnel (ex. "60ch", "480px") — vide par défaut (pleine # largeur de .docPageContent, comportement inchangé). Retour # 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() if 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'
{content}
' diff --git a/document_engine/rendering/rendering.md b/document_engine/rendering/rendering.md index f2e7094a..b722629c 100644 --- a/document_engine/rendering/rendering.md +++ b/document_engine/rendering/rendering.md @@ -29,12 +29,24 @@ regroupement à chaque appel. `justify-content` réels depuis `attributes`), enfants rendus récursivement. - **Texte** (`titre`/`paragraphe`) : `
` stylé selon `style` (préréglage - taille/graisse/interligne) et `bold`/`italic`/`underline`/`align`/`color`. - `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). + taille/graisse/interligne) et `bold`/`italic`/`underline`/`strikethrough`/ + `align`/`color`. `underline`/`strikethrough` se combinent dans un seul + `text-decoration` (`"underline line-through"` si les deux sont actifs). + `font_size`/`line_height` (vides par défaut) remplacent les valeurs du + préréglage `style` SANS toucher `font-weight` (toujours piloté par le + préréglage + `bold`). `text_transform` (`"none"` par défaut) ajoute + `text-transform` quand différent de `"none"`. `font_family`/ + `letter_spacing`/`text_shadow` (vides par défaut) ajoutent leur + déclaration CSS respective quand non vides. `max_width` (optionnel, ex. + `"60ch"`, `"480px"`) ajoute `max-width` au style inline quand non vide — + pleine largeur de `.docPageContent` par défaut, retour utilisateur du + 24/09/2026 (un paragraphe doit pouvoir rester plus étroit que la page, + sans dépendre d'une rangée qui en partagerait la largeur avec un frère). + Termine par `render_box_style(a)` (voir `box_style.py` ci-dessous) pour + `padding`/`margin`/`background_color`/`border_radius`/`border`/ + `align_self` — attributs PARTAGÉS avec d'autres kinds, jamais dupliqués + ici (audit du 26/09/2026, réglages manquants à couvrir élément par + élément en réutilisant ce module). - **Image** : ``, ou un bloc placeholder si `src` est vide — OU, si `attributes["svg_markup"]` est non vide (prioritaire sur `src`), un `
` 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 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(markup: str) -> str` diff --git a/static/document/document-editor.css b/static/document/document-editor.css index 0c1f20ea..171b2af1 100644 --- a/static/document/document-editor.css +++ b/static/document/document-editor.css @@ -2436,3 +2436,130 @@ img.docImage { 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; +} diff --git a/static/document/js/document-editor.js b/static/document/js/document-editor.js index 5b9dab19..8325feb5 100644 --- a/static/document/js/document-editor.js +++ b/static/document/js/document-editor.js @@ -282,6 +282,17 @@ function forgeDocUpdateAttributes(id, newAttributes) { forgeDocBindCanvasInteractions(); 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); } +/* --------------------------------------------------------------------- + * 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 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 ` +
+ ${label} +
+ + + ${hasValue ? numeric + displayUnit : '—'} +
+
+ `; +} + +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, + })} +
+ +
+ + +
+
+ ${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, + })} +
+ Ombre portée du bloc +
+ ${FORGE_DOC_SHADOW_PRESETS.map(([v, l]) => ``).join('')} +
+
+ ${forgeDocRenderToggleSliderHtml(`${idPrefix}Opacity`, 'Opacité', opacityPercent, { + min: 0, max: 100, step: 5, unit: '%', fallback: 100, + })} +
+ Position du bloc +
+ ${FORGE_DOC_ALIGN_SELF_OPTIONS.map(([v, l]) => ``).join('')} +
+
+
+ Bordure (par côté) +
+ ${FORGE_DOC_BORDER_SIDES.map(([side, label]) => { + const sideBorder = border[side] || {}; + return ` +
+ ${label} + + + +
`; + }).join('')} +
+
+ `; +} + +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) { const a = el.attributes; panel.innerHTML = ` @@ -1704,42 +1917,81 @@ function forgeDocRenderTextProps(panel, el) { +
- Alignement + Alignement du texte
- - - + + +
- Couleur du texte -
- ${['var(--doc-text)', 'var(--doc-muted)', '#ff5f2e', '#ffb020'].map((c) => `
`).join('')} + Majuscules / minuscules +
+ ${[['none', 'Normal'], ['uppercase', 'MAJUSCULES'], ['lowercase', 'minuscules'], ['capitalize', 'Capitales']].map(([v, l]) => ``).join('')}
-
+
+ + +
+
+ + +
+ ${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, + })} +
+ Ombre du texte +
+ ${FORGE_DOC_TEXT_SHADOW_PRESETS.map(([v, l]) => ``).join('')} +
+
+ ${forgeDocRenderToggleSliderHtml('docMaxWidth', 'Largeur maximale', a.max_width, { + min: 200, max: 1200, step: 20, unit: 'px', fallback: 600, + })} + ${forgeDocRenderBoxFieldsHtml(a, 'docText')} ${forgeDocDeleteButtonHtml()} `; function patch(partial) { forgeDocUpdateAttributes(el.id, { ...el.attributes, ...partial }); } 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) => { btn.addEventListener('click', () => patch({ style: btn.dataset.style })); }); document.getElementById('docBoldBtn').addEventListener('click', () => patch({ bold: !a.bold })); document.getElementById('docItalicBtn').addEventListener('click', () => patch({ italic: !a.italic })); 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) => { btn.addEventListener('click', () => patch({ align: btn.dataset.align })); }); - panel.querySelectorAll('#docColorSwatches .docSwatch').forEach((sw) => { - sw.addEventListener('click', () => patch({ color: sw.dataset.color })); + panel.querySelectorAll('#docTextTransformSeg .docSegBtn').forEach((btn) => { + 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) { diff --git a/tests/document/test_box_style.py b/tests/document/test_box_style.py new file mode 100644 index 00000000..9ab53e16 --- /dev/null +++ b/tests/document/test_box_style.py @@ -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": '">'} + style = render_box_style({"padding": '">', "border": border}) + assert "