Files
Forge-Engine/document_engine/rendering/rendering.md
T
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

22 KiB

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.