Files
williamandClaude Sonnet 5 4db1269348 Audit complet de mise en forme — Liste à puces/numérotée (4e élément)
Implémente toutes les options manquantes identifiées pour les listes :
typographie complète, style/position/couleur/taille de puce (validés
selon le kind), puce personnalisée en SVG pour les listes à puces,
padding uniforme par élément (nouveau, il n'y en avait aucun), espacement
entre éléments réglable, et tous les attributs de boîte partagés sur la
liste entière. Bordure/fond/padding par élément individuel et sous-listes
imbriquées volontairement différés (portée actée avec l'utilisateur
avant implémentation : transformeraient le stockage des éléments en
objets structurés, chantier bien plus lourd).

Trois ajouts transversaux bénéficiant à plusieurs éléments : sections
"Contenu"/"Conteneur" dans tous les panneaux de propriétés, alignement
vertical du contenu dans son bloc (Titre/Paragraphe/Liste/Image
légendée), et une option pour retirer un thème appliqué ("Aucun modèle"
dans la modale, avec une nouvelle fonction db.remove_document_theme).

Quatre bugs réels trouvés et corrigés en chaîne pendant la validation
avec le thème "Sécurité incendie" : un badge de thème s'affichait
au-dessus du texte au lieu d'à côté ; le correctif a d'abord fait
disparaître les puces/numéros natifs de TOUTES les listes (bug plus
grave que celui corrigé) ; puis un marqueur natif redondant apparaissait
à côté du badge du thème ; puis une règle CSS site-large de spécificité
supérieure empêchait silencieusement ce dernier correctif. Chaque étape
vérifiée par navigateur automatisé sur un support jetable.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-26 15:13:14 +02:00

345 lines
22 KiB
Markdown

# document_engine/rendering/
Rendu HTML du document — point d'entrée unique utilisé à la fois par le
canevas d'édition et par le Mode Aperçu (même fonction, voir
`docs/plan/PLAN.md` — "Aperçu réel").
## `render_document(elements: list[dict[str, Any]]) -> str`
Assemble le document entier à partir de la liste à plat renvoyée par
`document_engine.list_document_elements` : regroupe les éléments par
`parent_id`, puis rend récursivement les éléments top-niveau dans
l'ordre (une rangée rend elle-même ses propres enfants côte à côte).
- **Retour** : le HTML complet du document.
- **Exceptions** : aucune.
## `render_document_element(el: dict[str, Any], children_by_parent: dict[int | None, list[dict[str, Any]]]) -> str`
HTML d'un seul élément — dispatch par dict (table `_RENDERERS`) selon
`el["kind"]`, plutôt qu'un enchaînement de `if` (mirroir de l'esprit de
`game_engine/scenes/render_scene_object.py`). `children_by_parent` est le
regroupement précalculé par `render_document`, transmis pour que les
rangées puissent rendre récursivement leurs enfants sans refaire le
regroupement à chaque appel.
- **Retour** : le HTML de cet élément (et de ses enfants s'il s'agit
d'une rangée).
- **Exceptions** : aucune ; un `kind` inconnu produit un bloc
`docUnknown` visible plutôt qu'une levée d'exception.
### Détail des rendus par catégorie (fonctions privées, table de dispatch)
- **Rangée** (`row`) : conteneur flex (`gap`/`align-items`/
`justify-content` réels depuis `attributes`), enfants rendus
récursivement.
- **Texte** (`titre`/`paragraphe`) : `<div>` stylé selon `style` (préréglage
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** : `<img>`, ou un bloc placeholder si `src` est vide — OU, si
`attributes["svg_markup"]` est non vide (prioritaire sur `src`), un
`<div>` portant directement ce fragment SVG nettoyé par
`sanitize_svg_markup` (voir `sanitize_svg_markup.py` ci-dessous) : un
contenu vectoriel dessiné/collé par le créateur plutôt qu'un fichier
hébergé. Style inline : `object_fit` (`"cover"`/`"contain"`/`"fill"`,
toute autre valeur ignorée), `aspect_ratio` (valeur CSS libre, ex.
`"16 / 9"`), `filter_preset` (`"grayscale"`/`"sepia"`/`"blur"`, mappé
vers une vraie valeur `filter` CSS fixe — jamais une valeur de filtre
libre) + les attributs de boîte partagés (`render_box_style`, voir
`box_style.py`). `lazy_load` (`True`) ajoute `loading="lazy"` sur
l'`<img>` uniquement (comportement, pas du style). `click_behavior`
(`""`/`"link"`/`"lightbox"`) enveloppe le tout dans un `<a target="_blank"
rel="noopener noreferrer">` (si `link_url` est aussi renseigné) ou un
`<div class="docImageLightboxTrigger">` — les deux ne deviennent
réellement cliquables qu'en Mode Aperçu (voir static/document/js/
document-editor.js::forgeDocBindCanvasInteractions/
forgeDocOpenImageLightbox), même principe que les mini-jeux et la
pièce jointe d'un bouton. `caption` (non vide) enveloppe le tout dans
un `<figure><figcaption>` échappée.
- **Bouton** : `<button>` avec son `label` (enveloppé dans
`<span class="docButtonLabel">`), un `data-target` optionnel
(navigation) et un `data-attachment-filename` optionnel — marqueur
mécanique posé quand un fichier a été joint (voir
`routes/document/document_element_upload_attachment.py`), jamais
l'URL de téléchargement elle-même (ce renderer ne connaît pas le slug
du support ; `static/document/js/document-editor.js` l'assemble à
partir de `data-element-id` + `FORGE_DOCUMENT.slug`, même principe que
le reste des appels AJAX de l'éditeur). Style inline (audit du
26/09/2026, réglages manquants — colonne "Bouton") : `bold` pousse
`font-weight` à `800` (jamais en dessous du `700` déjà posé par le CSS
de base — "gras" ne fait que renforcer, jamais affaiblir, aucune
régression visuelle sur les boutons déjà créés), `italic`/
`text_transform` (`"uppercase"`/`"lowercase"`/`"capitalize"`, jamais
`"none"`)/`font_family`/`font_size`/`letter_spacing`/`text_color`
(vides par défaut) + les attributs de boîte partagés
(`render_box_style`, voir `box_style.py`). `svg_markup` (optionnel,
nettoyé par `sanitize_svg_markup`) ajoute un `<span
class="docButtonIcon">` avant OU après `.docButtonLabel` selon
`icon_position` (`"before"` par défaut), dimensionné par `icon_size`
(vide = `1em`, suit la taille du texte). États interactifs
survol/actif : effet CSS universel (`filter`/`transform`, voir
`static/document/document-editor.css`), jamais configurable par
attribut — pas de notion d'état "désactivé" pour un bouton de contenu
(ce n'est pas un vrai contrôle de formulaire).
- **Étiquette** (`badge`) : `<div>` portant `attributes["content"]`
échappé, précédé d'un `<span class="docBadgeIcon">` optionnel si
`svg_markup` est non vide (nettoyé par `sanitize_svg_markup`, même
mécanisme que le mode SVG de `"image"`). `width` (non vide) ajoute
`align-self:flex-start; width:{valeur};` en style inline — fixer une
largeur implique TOUJOURS de sortir de l'étirement pleine largeur par
défaut d'un enfant flex en colonne (voir static/document/
document-editor.css, `.docPageContent`), jamais l'un sans l'autre.
`border_radius` (non vide) ajoute `border-radius:{valeur};`. `bold`/
`uppercase` ajoutent respectivement `font-weight:800;`/
`text-transform:uppercase;` quand `True`. Tous ces attributs sont vides
ou `False` par défaut (comportement historique inchangé, aucun style
inline ajouté).
- **Carte** (`carte`) : `<div>` composé de trois blocs enfants
(`label`/`title`/`description`, tous échappés) — contenu pur, aucune
couleur/forme choisie ici (voir `element_kind_labels.md`).
- **Liste à puces/numérotée** (`liste_puces`/`liste_numerotee`) :
`<ul>` ou `<ol>` selon le `kind` (fonction privée `_render_list`,
partagée par les deux) — un `<li>` par entrée de `attributes["items"]`.
Une liste vide rend `<ul>`/`<ol>` sans enfant plutôt qu'un placeholder :
contrairement à une image sans fichier, ce n'est pas un état anormal.
Style inline (audit du 26/09/2026, portée actée avec l'utilisateur :
la LISTE ENTIÈRE uniquement, jamais par élément individuel ni de
sous-listes) : `bold`/`italic`/`underline`/`font_family`/`font_size`/
`line_height`/`text_color` (typo, `_list_text_style`) +
`list_style_type` (validé selon le `kind` — `disc`/`circle`/`square`/
`none` pour `liste_puces`, `decimal`/`decimal-leading-zero`/
`lower-roman`/`upper-roman`/`lower-alpha`/`upper-alpha`/`none` pour
`liste_numerotee`, toute autre valeur ignorée)/`list_style_position`
(`"outside"` par défaut) + les attributs de boîte partagés
(`render_box_style`, voir `box_style.py`). `marker_color`/
`marker_size`/`item_padding`/`item_spacing` (`_list_marker_style`/
`_list_item_style`) passent par des PROPRIÉTÉS PERSONNALISÉES CSS
(`--doc-marker-color`/`--doc-marker-size`/`--doc-item-padding`/
`--doc-item-spacing`) : un style inline sur le `<ul>`/`<ol>` ne peut
pas cibler directement le `::marker` ou le padding de ses `<li>`
enfants autrement — ces propriétés sont posées sur le conteneur et
consommées par `static/document/document-editor.css`
(`.docList li`/`.docList li::marker`), qui hérite jusque-là.
`svg_markup` (liste à puces UNIQUEMENT, ignoré pour `liste_numerotee`)
: puce personnalisée — nettoyé par `sanitize_svg_markup` puis encodé
en URI de données (`urllib.parse.quote`) pour `list-style-image`, qui
prime visuellement sur `list_style_type` dès qu'il est posé.
`render_content_align(a)` (voir `box_style.py`) également ajouté :
`.docList` est `display:flex; flex-direction:column;` (un `<li>` garde
son `display:list-item` propre — puce/numéro visibles — même une fois
flex-item, ce sont deux notions indépendantes en CSS).
**Bug réel corrigé** (retour utilisateur du 26/09/2026 : "si j'enlève
les puces ou que les puces se mettent à l'intérieur, il reste un
espace devant la liste, cet espace doit être supprimé") :
`padding-left:0;` est ajouté automatiquement dès que
`list_style_type="none"` ou `list_style_position="inside"` — le
`padding-left:1.4em` par défaut (`.docList`, réservé pour une puce
EXTÉRIEURE) n'a alors plus lieu d'être ; un `padding` uniforme réglé
explicitement par ailleurs (attributs de boîte partagés) reste
prioritaire (déclaré après, dans le même style inline).
- **Quiz** : toujours une carte résumant la config réelle (nombre de
questions, total des points via `quiz_total_points`, minuteur si
activé) — sanitizée (`sanitize_quiz_config`) avant lecture, jamais un
rendu direct d'`attributes` brut. Si au moins une question existe,
s'y ajoute (fonction privée `_render_quiz_player`) le questionnaire
RÉEL et interactif affiché en Mode Aperçu (voir docs/plan/maquettes/
document-formation-web.html — référence visuelle), masqué en édition
par CSS (`.docQuizPlayer`, voir static/document/document-editor.css) :
les questions sont embarquées en JSON dans un attribut `data-quiz-config`
(jamais un `<script>` par élément), échappé pour l'HTML
(`html.escape(..., quote=True)`) — static/document/js/document-editor.js
lit cet attribut et gère tout le déroulé (réponse/score/question
suivante/résultat) côté client, sans aucun aller-retour serveur.
- **Association** : toujours une carte résumant la config réelle (nombre
de paires) — sanitizée (`sanitize_association_config`) avant lecture.
Si au moins une paire existe, s'y ajoute (fonction privée
`_render_association_player`) le plateau de glisser-déposer RÉEL et
interactif affiché en Mode Aperçu, masqué en édition par CSS
(`.docAssocPlayer`) : les deux colonnes (termes/correspondances) sont
mélangées INDÉPENDAMMENT (`random.shuffle`, mélange d'affichage — voir
`CODE_QUALITY.md`) puis embarquées en JSON dans un attribut
`data-assoc-config`, échappé pour l'HTML — même principe que le Quiz,
aucun aller-retour serveur pendant qu'on joue. Contrairement au Quiz,
le plateau reste affiché en permanence une fois la partie terminée :
seul le bouton "Recommencer" (`.docMinigameRestartBar`, partagé avec
Memory) apparaît, jamais d'écran de résultat séparé qui le
remplacerait.
- **Memory** : toujours une carte résumant la config réelle (nombre de
cartes définies, mode paire/simple) — sanitizée
(`sanitize_memory_config`) avant lecture. Si au moins une carte existe,
s'y ajoute (fonction privée `_render_memory_player`) le plateau de
retournement RÉEL et interactif : en mode `"paire"`, chaque carte
définie est DUPLIQUÉE en deux instances partageant le même
`card_index` (l'appariement se fait dessus, classique Memory) ; en mode
`"single"`, une seule instance par carte (simple retournement, sans
appariement). Les instances sont mélangées (`random.shuffle`, mélange
d'affichage — voir `CODE_QUALITY.md`) puis embarquées en JSON dans un
attribut `data-memory-config`, échappé pour l'HTML — même principe que
le Quiz/l'Association, aucun aller-retour serveur pendant qu'on joue.
Comme l'Association, le plateau reste affiché une fois toutes les
paires trouvées (ou toutes les cartes révélées en mode `"single"`) :
seul le bouton "Recommencer" (`.docMinigameRestartBar`) apparaît.
- **Mots mêlés** : toujours une carte résumant la config réelle (nombre
de mots) — sanitizée (`sanitize_mots_config`) avant lecture. Si au
moins un mot existe, s'y ajoute (fonction privée `_render_mots_player`)
la grille RÉELLE et interactive. La grille ET la position exacte de
chaque mot sont calculées ICI côté serveur (`_build_mots_grid`, jamais
recalculées côté client) : chaque mot est placé horizontalement,
verticalement, ou en diagonale (haut-gauche→bas-droite ou
haut-droite→bas-gauche — jamais à l'envers), les lettres restantes
tirées au hasard (`random.choice`/`random.randint`, tirage de jeu —
voir `CODE_QUALITY.md`) ; si un mot ne trouve pas sa place, la grille
entière est agrandie et le placement retenté depuis zéro, plutôt que
d'abandonner silencieusement ce mot. Le tout (grille + coordonnées de
chaque mot) est embarqué en JSON dans un attribut `data-mots-config`,
échappé pour l'HTML — même principe que les autres mini-jeux, aucun
aller-retour serveur pendant qu'on joue : `static/document/js/
document-editor.js` compare les coordonnées EXACTES sélectionnées par
l'apprenant à celles de chaque mot (jamais une simple comparaison de
texte, qui se tromperait sur des lettres partagées entre deux mots qui
se croisent). Comme l'Association/Memory, la grille reste affichée une
fois tous les mots trouvés : seul le bouton "Recommencer"
(`.docMinigameRestartBar`) apparaît.
- **Scénario** : toujours une carte résumant la config réelle (nombre de
scénarios) — sanitizée (`sanitize_scenario_config`) avant lecture. Si
au moins un scénario existe, s'y ajoute (fonction privée
`_render_scenario_player`) la mise en situation RÉELLE et interactive :
un scénario est un ARBRE DE DÉCISION (voir `scenario_config.py` pour la
forme exacte — `nodes[0]` = situation initiale, chaque choix pointe
vers un autre nœud via `target_id`, un nœud sans choix est une fin de
branche), pas une simple question à une seule conséquence. Aucune
notion de bonne/mauvaise réponse (retour utilisateur du 20/09/2026 :
"il n'y a pas de notion vrai/faux, l'utilisateur observe les
conséquences") : l'apprenant choisit une option, le texte du nœud visé
REMPLACE l'affichage du nœud précédent (les boutons de choix
disparaissent avec lui), et ainsi de suite jusqu'à une fin de branche
— volontairement PAS le comportement du Quiz, où la question resterait
affichée à côté d'un encart de feedback séparé. Une fois une fin de
branche atteinte, passage au scénario (arbre) suivant. Les scénarios
gardent l'ORDRE d'écriture du créateur (jamais mélangés, contrairement
à Association/Memory/Mots mêlés — ce sont des mises en situation
séquentielles, pas des éléments à faire correspondre/retrouver).
Réutilise les classes visuelles du Quiz (`.docQuizOptions`/
`.docQuizNextBar`/`.docQuizQuestionText`) plutôt que de dupliquer ces
règles. L'arbre complet (tous les nœuds/choix de tous les scénarios)
est embarqué en JSON dans un attribut `data-scenario-config`, échappé
pour l'HTML — même principe que les autres mini-jeux, aucun
aller-retour serveur pendant qu'on joue : toute la navigation dans
l'arbre se fait côté client. Comme l'Association/Memory/Mots mêlés
(mais contrairement au Quiz), le dernier scénario reste affiché une
fois une fin de branche atteinte : seul le bouton "Recommencer"
(`.docMinigameRestartBar`) apparaît. L'arbre lui-même se construit
dans une MODALE dédiée depuis le panneau Propriétés (voir
`forgeDocOpenScenarioTreeModal`, static/document/js/document-editor.js)
— trop de structure (nœuds + choix + destinations) pour la colonne
étroite du panneau Propriétés, contrairement aux autres mini-jeux. La
modale est un VRAI graphe visuel (retour utilisateur du 21/09/2026) :
chaque nœud a une position `x`/`y` (voir `scenario_config.py`),
affiché comme une carte déplaçable à la souris sur un canevas ; chaque
choix relié à un `target_id` est dessiné comme une flèche SVG
étiquetée par son texte, jamais un simple menu déroulant. Éditer le
texte/les choix d'un nœud se fait dans l'inspecteur (panneau de
droite) du nœud sélectionné ; relier un choix se fait en cliquant
"Relier" puis le nœud cible sur le graphe (mode connexion, Échap
annule).
- **Autres mini-jeux** (`zones`) : carte placeholder portant le libellé
du type (voir `document_engine/labels/element_kind_labels.py`) —
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", "width": "", "max_width": "", "height": "",
"min_height": "", "max_height": "", "min_width": "", "box_shadow": "",
"opacity": "", "content_align": "top"}` — `border` n'y figure PAS (voir
`default_border()`, à ajouter séparément par chaque appelant pour éviter
le partage par référence).
### `render_box_style(a: dict[str, Any]) -> str`
Construit les déclarations CSS inline pour chaque attribut listé dans
`BOX_DEFAULTS` (padding/margin/background_color/border_radius/width/
max_width/height/min_height/max_height/min_width/box_shadow/opacity) +
`border`/`align_self` de `a`, via une table de correspondance
(clé d'attribut, propriété CSS) plutôt qu'un bloc `if` par attribut
(complexité cognitive — voir `_render_simple_properties`/`_render_border`,
privées) — 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.
### `render_content_align(a: dict[str, Any]) -> str`
Alignement vertical du CONTENU à l'intérieur de son propre bloc (retour
utilisateur du 26/09/2026 : "je peux augmenter la hauteur d'un
conteneur mais pas l'alignement vertical à l'intérieur") — `content_align`
(`"top"` par défaut, `"center"` ou `"bottom"`) mappé vers
`justify-content` (`""` pour `"top"`, comportement historique inchangé).
Jamais fusionné dans `render_box_style` : contrairement à `align_self`
(position du BLOC dans SON parent, valable pour tout consommateur),
l'alignement du CONTENU dépend de l'axe interne du conteneur —
`justify-content` convient à un conteneur en colonne (`_render_text`/
`_render_list`, dont les classes CSS `.docText`/`.docList` sont
`display:flex; flex-direction:column;`, et la figure d'une image
légendée, déjà flex-colonne), mais serait FAUX pour le Bouton (rangée
icône+texte : l'axe vertical y est déjà géré par `align-items`, voir
`static/document/document-editor.css`, `.docButton`) — chaque renderer
qui veut ce comportement l'appelle donc explicitement lui-même.
- **Retour** : `""` si absent/`"top"`/valeur inconnue, sinon
`"justify-content:...;"`.
- **Exceptions** : aucune.
## `sanitize_svg_markup.py` — nettoyage du contenu SVG inline d'une image
### `sanitize_svg_markup(markup: str) -> str`
Nettoie un fragment SVG selon une LISTE BLANCHE de balises/attributs
(`_ALLOWED_TAGS`/`_ALLOWED_ATTRS`, privées) — construit sur
`html.parser.HTMLParser` (tokenizer de balises pur, sans DTD ni
résolution d'entité externe) plutôt qu'un analyseur XML, qui resterait
exposé aux attaques classiques d'entité externe sur une entrée non
fiable. Toute balise absente de la liste blanche (`<script>`,
`<foreignObject>`, `<a>`, `<use>`, `<image>`...) disparaît AVEC son
contenu ; tout attribut absent (`on*`, `style`, `href`/`xlink:href`,
`class`...) disparaît seul, la balise porteuse étant conservée si elle
est autorisée. Appelé à CHAQUE rendu (`_render_image`), jamais
seulement à l'écriture — même défense en profondeur que
`html.escape` sur les autres kinds.
- **Retour** : le fragment SVG nettoyé, sûr à insérer tel quel dans le
HTML rendu.
- **Exceptions** : aucune.