// Export Web/SCORM (voir le plan "port complet du runtime jouable côté // navigateur") — port de screens/rendering/render_element_html.py et de // ses petites briques (attr_string.py, apply_ctx.py, style_string.py, // visible_attrs.py, parent_flex_direction.py, auto_id.py), regroupées ici // (un seul fichier JS plutôt qu'un par brique Python : elles sont trop // petites individuellement pour justifier un fichier chacune côté client). // // Widgets à balise simple (texte, titre, bouton, lien, image, vidéo, // champs de saisie, listes, séparateur, conteneur) rendus directement // dans ce fichier ; les 10 widgets à "special_render" (case à cocher/ // radio, liste déroulante, groupe de champs, tableau, répéteur, jauge, // superposition, onglets, icône, personnage) vivent chacun dans leur // propre fichier (render-jauge.js, render-overlay.js, render-repeater.js, // render-special-widgets.js, chargés avant celui-ci dans templates/ // play.html) et sont routés depuis forgeRenderElementHtml() ci-dessous // via forgeSpecialRenderer(). const FORGE_LAYOUT_CAPABLE_WIDGETS = ['conteneur', 'repeteur_donnees', 'liste', 'liste_numerotee', 'groupe_champs', 'tableau']; // Widgets à balise simple portés à l'étape 3 — {tag, hasContent, // selfClosing, fixedAttrs} — port direct de screens/widgets/registry.py // (seuls les champs utiles au RENDU, pas au panneau de propriétés de // l'éditeur, qui ne concerne que le port serveur). const FORGE_SIMPLE_WIDGETS = { texte: { tag: 'p', hasContent: true }, titre: { tag: null, hasContent: true, fixedAttrs: { class: 'title' } }, bouton: { tag: 'button', hasContent: true, fixedAttrs: { class: 'button' } }, lien: { tag: 'a', hasContent: true }, image: { tag: 'img', hasContent: false, selfClosing: true }, video: { tag: 'video', hasContent: false }, champ_texte: { tag: 'input', hasContent: false, selfClosing: true, fixedAttrs: { type: 'text', class: 'input' }, }, champ_email: { tag: 'input', hasContent: false, selfClosing: true, fixedAttrs: { type: 'email', class: 'input' }, }, champ_mot_de_passe: { tag: 'input', hasContent: false, selfClosing: true, fixedAttrs: { type: 'password', class: 'input' }, }, zone_texte: { tag: 'textarea', hasContent: false, fixedAttrs: { class: 'textarea' } }, liste: { tag: 'ul', hasContent: true }, liste_numerotee: { tag: 'ol', hasContent: true }, separateur: { tag: 'hr', hasContent: false, selfClosing: true }, conteneur: { tag: 'div', hasContent: true, fixedAttrs: { class: 'box' } }, }; // widget -> clé de special_render (voir screens/widgets/registry.py). const FORGE_SPECIAL_RENDER_BY_WIDGET = { case_a_cocher: 'checkbox_label', bouton_radio: 'checkbox_label', liste_deroulante: 'select_options', groupe_champs: 'fieldset_legend', tableau: 'table', repeteur_donnees: 'repeater', jauge: 'jauge', superposition: 'overlay', onglets: 'onglets', icone: 'icone', personnage: 'personnage', }; // fixed_attrs des widgets à special_render (voir screens/widgets/registry.py) // — seul "tableau" en a un, tous les autres n'en ont aucun (contrairement // à "conteneur", qui ne doit JAMAIS servir de repli pour eux). const FORGE_SPECIAL_WIDGET_FIXED_ATTRS = { tableau: { class: 'table' } }; function forgeHtmlEscape(s) { return String(s) .replace(/&/g, '&').replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } // Port de apply_ctx.py — {{champ}} remplacé par la valeur de la ligne en // cours (contenu ET attributs), à l'intérieur d'un Répéteur. function forgeApplyCtx(text, ctx) { if (!ctx || !text) return text; let result = text; Object.keys(ctx).forEach((k) => { if (k === '_forge_play_mode') return; // indicateur interne, pas un {{champ}} result = result.split(`{{${k}}}`).join(ctx[k]); }); return result; } function forgeAutoId(el) { return `elt-${el.id}`; } // Port de parent_flex_direction.py. function forgeParentFlexDirection(parentEl) { const style = (parentEl && parentEl.style) || {}; if (style.display !== 'flex') return null; return style['flex-direction'] || 'row'; } // Port de style_string.py — même logique de taille par défaut (100% hors // répéteur/conteneur, adaptée au contenu + à l'axe flex du parent pour un // enfant), même garde-fous (transform réservé au cadre extérieur pour un // élément posé directement sur l'écran, 0px ignoré = pas de surcharge). function forgeStyleString(el, parentFlexDirection, widgetKey) { const style = { ...el.style || {} }; if (FORGE_LAYOUT_CAPABLE_WIDGETS.indexOf(widgetKey) !== -1 && !('display' in style)) { style.display = 'flex'; if (!('flex-direction' in style)) style['flex-direction'] = 'column'; } const isChild = !!el.parent_id; let baseStyle; if (isChild) { const widthDefault = (parentFlexDirection === 'row' || parentFlexDirection === 'row-reverse') ? 'auto' : '100%'; baseStyle = `width:${widthDefault}; height:auto; box-sizing:border-box; margin:0;`; } else { baseStyle = 'width:100%; height:100%; box-sizing:border-box; margin:0;'; } let hasSizeOverride = false; const parts = []; Object.keys(style).forEach((k) => { const v = style[k]; if (v === null || v === undefined || v === '') return; if (typeof v === 'object') return; // filet de sécurité, voir style_string.py if (k === 'transform' && !isChild) return; const strV = String(v).trim(); if ((k === 'width' || k === 'height') && (strV === '0px' || strV === '0')) return; if (k === 'width' || k === 'height') hasSizeOverride = true; parts.push(`${k}:${v}`); }); if (hasSizeOverride) parts.push('flex-shrink:0'); const userStyle = parts.join('; '); return baseStyle + (userStyle ? ` ${userStyle};` : ''); } // Port de attr_string.py. "alt" est le seul attribut dont la chaîne VIDE // a un sens propre (RGAA/WCAG 1.1.1 : alt="" marque explicitement une // image comme décorative, ce que "pas d'alt du tout" ne fait pas pour un // lecteur d'écran) — tous les autres attributs vides sont omis comme // avant (une valeur vide y équivaut à "non réglé"). function forgeAttrString(attrs, extraStyle) { const parts = [`style="${forgeHtmlEscape(extraStyle)}"`]; Object.keys(attrs).forEach((k) => { const v = attrs[k]; if (v === null || v === undefined || v === false) return; if (v === '' && k !== 'alt') return; parts.push(v === true ? forgeHtmlEscape(k) : `${forgeHtmlEscape(k)}="${forgeHtmlEscape(v)}"`); }); return parts.join(' '); } // Port de visible_attrs.py. function forgeVisibleAttrs(el, meta, ctx) { const rawAttrs = el.attributes || {}; const attrs = {}; Object.keys(rawAttrs).forEach((k) => { if (k.indexOf('_') === 0) return; const v = rawAttrs[k]; attrs[k] = typeof v === 'string' ? forgeApplyCtx(v, ctx) : v; }); Object.assign(attrs, meta.fixedAttrs || {}); const extraClasses = Object.keys(rawAttrs) .filter((k) => k.indexOf('_class_') === 0 && rawAttrs[k]) .map((k) => rawAttrs[k]); if (extraClasses.length) { const baseClass = attrs.class || ''; attrs.class = (baseClass ? [baseClass] : []).concat(extraClasses).join(' '); } if (!('id' in attrs)) attrs.id = forgeAutoId(el); attrs['data-element-id'] = el.id; if (el.parent_id) attrs['data-anim-target'] = el.id; return attrs; } // Port de render_children.py. function forgeRenderChildren(el, gameData, childrenMap, ctx) { const kids = (childrenMap && childrenMap[el.id]) || []; const flexDir = forgeParentFlexDirection(el); return kids.map((c) => forgeRenderElementHtml(c, gameData, childrenMap, ctx, flexDir)).join(''); } // Port de resolve_bound_row.py (_resolve_bound_row_ctx) — "Donnée liée" // d'un Texte/Titre : retrouve LA ligne qui correspond aux conditions // réglées (moteur partagé avec le Répéteur, voir filter-repeater-rows.js). function forgeResolveBoundRowCtx(gameData, attrs) { let definitionId = attrs._data_definition_id; if (!definitionId) return null; definitionId = String(definitionId); const fieldsMetaForDef = gameData.fields_meta && gameData.fields_meta[definitionId]; if (!fieldsMetaForDef) return null; let rows = (gameData.data && gameData.data[definitionId]) || []; const decoded = forgeDecodeClauses(attrs, '_data_filtres_json', '_data_filtre_combinateur', '_data_filtre'); rows = forgeFilterRowsByClauses(gameData, definitionId, rows, decoded.clauses, decoded.combinator); if (!rows.length) return null; const row = rows[0]; const ctx = {}; fieldsMetaForDef.forEach((f) => { // row[f.name] : gameData.data est déjà indexé par le nom D'AFFICHAGE // du champ (voir full_game_payload.py), jamais par sa colonne SQL — // voir filter-repeater-rows.js pour le détail de ce piège. const v = row[f.name]; ctx[f.name] = (v === null || v === undefined || v === '') ? '' : String(v); }); return ctx; } // Port de render_element_html.py:_render_element_type_children — contenu // d'un exemplaire d'élément de jeu réutilisable posé sur un écran, rejoué // à chaque affichage depuis gameData.element_type_templates (précalculé // une fois à l'export, voir full_game_payload.py) plutôt que depuis // d'éventuels enfants copiés en base. function forgeRenderElementTypeChildren(el, gameData, ctx) { const templateTree = gameData.element_type_templates && gameData.element_type_templates[String(el.element_type_id)]; if (!templateTree || !templateTree.top.length) return [null, false]; const isOverlayOnly = templateTree.top.every((c) => c.widget === 'superposition'); let { top } = templateTree; if (isOverlayOnly) { const overlayRoot = { ...top[0] }; const newStyle = {}; Object.keys(overlayRoot.style || {}).forEach((k) => { if (k !== 'display') newStyle[k] = overlayRoot.style[k]; }); overlayRoot.style = newStyle; top = top.slice(); top[0] = overlayRoot; } const html = top.map((c) => forgeRenderElementHtml(c, gameData, templateTree.children_map, ctx)).join(''); return [html, isOverlayOnly]; } // Les 10 widgets à special_render (voir render-jauge.js, render-overlay.js, // render-repeater.js, render-special-widgets.js — chargés avant ce // dispatcher dans templates/play.html) sont tous portés (étape 4 du plan // terminée) ; ce registre reste indirection unique nécessaire pour éviter // une dépendance circulaire fichier-à-fichier (chacun de ces fichiers a // besoin, en retour, de forgeRenderElementHtml ci-dessous pour ses propres // enfants/onglets/superposition). function forgeSpecialRenderer(specialKey) { switch (specialKey) { case 'checkbox_label': return forgeRenderCheckboxOrRadio; case 'select_options': return forgeRenderSelect; case 'fieldset_legend': return forgeRenderFieldset; case 'table': return forgeRenderTable; case 'repeater': return forgeRenderRepeater; case 'jauge': return forgeRenderJauge; case 'overlay': return forgeRenderOverlay; case 'onglets': return forgeRenderOnglets; case 'icone': return forgeRenderIcone; case 'personnage': return forgeRenderPersonnage; default: return null; } } // Port de render_element_html.py — dispatcher principal. `slug` n'existe // pas hors ligne (une seule partie en mémoire, pas de base de données) : // tout ce qui en dépendait côté serveur lit désormais `gameData`. function forgeRenderElementHtml(el, gameData, childrenMap, ctx, parentFlexDirection) { const widgetKey = el.widget; const simple = FORGE_SIMPLE_WIDGETS[widgetKey]; const specialKey = FORGE_SPECIAL_RENDER_BY_WIDGET[widgetKey]; const attrsRaw = el.attributes || {}; const hasVisibilityCondition = attrsRaw._visibilite_cond_mode === 'variable' || attrsRaw._visibilite_cond_mode === 'objet'; const playMode = !!((ctx || {})._forge_play_mode); if (playMode && hasVisibilityCondition && !forgeIsElementVisibleOffline(gameData, attrsRaw, ctx)) { return ``; } const hasDataBinding = !!attrsRaw._data_definition_id; const mark = function (htmlStr) { let markers = ''; if (hasVisibilityCondition) markers += ''; if (hasDataBinding) markers += ''; return htmlStr + markers; }; const boundCtx = forgeResolveBoundRowCtx(gameData, attrsRaw); if (boundCtx) ctx = { ...ctx || {}, ...boundCtx }; // meta d'un widget à special_render : fixedAttrs propre (voir // screens/widgets/registry.py — seul "tableau" en a un, "class":"table"), // jamais celui de "conteneur" ("class":"box" polluerait tout le reste). const meta = simple || { fixedAttrs: FORGE_SPECIAL_WIDGET_FIXED_ATTRS[widgetKey] || {} }; if (specialKey) { const renderer = forgeSpecialRenderer(specialKey); return mark(renderer(el, meta, gameData, childrenMap, ctx, parentFlexDirection)); } let { tag } = meta; if (tag === null) { // widget à balise dynamique (ex: "titre" -> h1/h2/h3) const niveau = attrsRaw._niveau || '2'; tag = `h${niveau}`; } const attrs = forgeVisibleAttrs(el, meta, ctx); const style = forgeStyleString(el, parentFlexDirection, widgetKey); const hoverText = attrsRaw._survol_texte; if (hoverText) attrs['data-hover-text'] = forgeApplyCtx(hoverText, ctx); // alt (RGAA/WCAG 1.1.1) : le widget "image" n'a pas de champ de // description dédié dans l'éditeur — alt="" (décoratif) par défaut // plutôt qu'aucun attribut du tout, jamais le nom de fichier brut par // défaut du navigateur. Ne touche pas à un alt déjà posé (pass-through // via forgeVisibleAttrs, si l'auteur en règle un un jour). if (tag === 'img' && !('alt' in attrs)) attrs.alt = ''; if (meta.selfClosing) { return mark(`<${tag} ${forgeAttrString(attrs, style)}>`); } const content = forgeApplyCtx(el.content || '', ctx); const childResult = el.element_type_id ? forgeRenderElementTypeChildren(el, gameData, ctx) : [null, false]; let childHtml = childResult[0]; const isOverlayTemplate = childResult[1]; if (childHtml === null) { childHtml = forgeRenderChildren(el, gameData, childrenMap, ctx); } else if (isOverlayTemplate) { delete attrs.class; return mark(`
${childHtml}
`); } if (tag === 'ul' || tag === 'ol') { const items = content.split('\n').map((l) => l.trim()).filter(Boolean); const inner = items.map((item) => `
  • ${forgeHtmlEscape(item)}
  • `).join('') + childHtml; return mark(`<${tag} ${forgeAttrString(attrs, style)}>${inner}`); } const safeContent = content ? forgeHtmlEscape(content).replace(/\n/g, '
    ') : ''; return mark(`<${tag} ${forgeAttrString(attrs, style)}>${safeContent}${childHtml}`); } // NOTE (node:test uniquement) : forgeRenderElementHtml() référence, pour // les 10 widgets à special_render, des fonctions définies dans D'AUTRES // fichiers (render-jauge.js, render-overlay.js, render-repeater.js, // render-special-widgets.js) — de vrais globaux dans le navigateur (tous // chargés ensemble par templates/play.html), mais qui n'existent PAS // automatiquement ici : un test qui exerce le dispatcher sur un widget à // special_render doit passer par __tests__/_load_offline_runtime.js // (charge tout, dans l'ordre, comme le fait play.html) plutôt que // require('../render-element.js') seul — sans quoi requérir ces fichiers // D'ICI créerait un cycle (ils ont eux-mêmes besoin, en retour, des // fonctions ci-dessus pour leurs propres enfants/onglets/superposition). if (typeof module !== 'undefined' && module.exports) { module.exports = { forgeHtmlEscape, forgeApplyCtx, forgeAutoId, forgeParentFlexDirection, forgeStyleString, forgeAttrString, forgeVisibleAttrs, forgeRenderChildren, forgeRenderElementHtml, forgeResolveBoundRowCtx, forgeRenderElementTypeChildren, forgeSpecialRenderer, FORGE_SIMPLE_WIDGETS, FORGE_SPECIAL_RENDER_BY_WIDGET, }; global.forgeIsElementVisibleOffline = global.forgeIsElementVisibleOffline || require('./visibility-condition.js').forgeIsElementVisibleOffline; const filterMod = require('./filter-repeater-rows.js'); global.forgeResolveFilterValue = global.forgeResolveFilterValue || filterMod.forgeResolveFilterValue; global.forgeResolveVariablePath = global.forgeResolveVariablePath || filterMod.forgeResolveVariablePath; global.forgeDecodeClauses = global.forgeDecodeClauses || filterMod.forgeDecodeClauses; global.forgeFilterRowsByClauses = global.forgeFilterRowsByClauses || filterMod.forgeFilterRowsByClauses; global.compareValues = global.compareValues || require('../conditions.js').compareValues; }