Implémente toutes les options manquantes identifiées pour l'élément Image : dimensionnement/ratio/object-fit, filtres CSS, upload de fichier (en plus de l'URL), lien/plein écran au clic, chargement différé, légende, et tous les attributs de boîte partagés déjà créés pour Titre/Paragraphe (padding/margin/fond/bordure/ombre/opacité/ position du bloc). Système de pages : un support peut désormais avoir 0 page (un nouveau support démarre vide), suppression de toutes les pages en un clic, et la pagination automatique insère intelligemment la nouvelle page juste après celle qui déborde plutôt qu'en toute fin de liste. Bugs réels trouvés et corrigés en cours de route : le style de bloc (dont align-self) ciblait l'élément interne au lieu de son enveloppe (légende/lien) ; une image à sa taille native pouvait déclencher une pagination infinie ; upload/mise à jour d'attribut ne déclenchaient jamais le contrôle de débordement. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
273 lines
17 KiB
Markdown
273 lines
17 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`, 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", "width": "", "max_width": "", "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 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.
|
|
|
|
## `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.
|