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>
254 lines
16 KiB
Markdown
254 lines
16 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é, sans aucun style qui lui soit propre.
|
|
- **Bouton** : `<button>` avec son `label`, 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).
|
|
- **É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.
|
|
- **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", "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`
|
|
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.
|