diff --git a/publish/build_scorm_package.py b/publish/build_scorm_package.py index 5ada3bb5..2edbd65f 100644 --- a/publish/build_scorm_package.py +++ b/publish/build_scorm_package.py @@ -7,6 +7,7 @@ SCORM tourne SEUL dans le LMS du client, sans serveur Forge Engine disponible. Rien de commun avec le zip .exe hors le sous-ensemble de static/ et _copy_characters (mêmes assets, mêmes règles de copie sélective) — réutilisés depuis build_package plutôt que dupliqués.""" +import base64 import json import os import shutil @@ -71,14 +72,15 @@ def _relativize_absolute_urls(html, slug): Cas particulier des icônes (render_icone.py) : leur URL est posée dans un style INLINE (--icon-url), mais CONSUMÉE par `mask-image: - var(--icon-url)` dans static/style.css (voir .icon-svg) — une URL - relative dans une PROPRIÉTÉ PERSONNALISÉE CSS se résout par rapport à - la feuille de style où le var() est UTILISÉ, pas où elle est définie - (piège CSS connu). "icons/x.svg" (juste "/static/" retiré, sans le - garder) reste donc correct une fois résolu depuis static/style.css, - alors que "static/icons/x.svg" y redevient "static/static/icons/x.svg" - (bug signalé — CORS bloqué sur ce chemin doublé). Traité EN PREMIER, - plus spécifique que le remplacement générique juste après.""" + var(--icon-url)` dans static/style.css (voir .icon-svg) — un chemin + RELATIF n'y suffit PAS (voir _build_icon_data_uris plus bas, qui + remplace ces chemins par des data: URI juste après cet appel) : Firefox + traite chaque ressource file:// comme une origine opaque distincte, et + mask-image y déclenche un fetch en mode CORS quel que soit le chemin. + "icons/x.svg" reste néanmoins la forme intermédiaire correcte que + _build_icon_data_uris recherche pour remplacer (elle-même résolue par + rapport à static/style.css, jamais index.html — piège CSS connu : + "static/icons/x.svg" y redeviendrait "static/static/icons/x.svg").""" return ( html.replace(f"/game/{slug}/uploads/", "uploads/") .replace("/static/icons/", "icons/") @@ -86,6 +88,65 @@ def _relativize_absolute_urls(html, slug): ) +def _collect_icon_slugs(payload): + """Tout _icon_slug (widget "icone") réellement posé dans ce jeu — + élément normal ET élément niché dans un élément de jeu réutilisable + (element_type_templates, voir full_game_payload.py) — pour ne pas + embarquer ("data:" URI, voir _build_icon_data_uris) le catalogue + ENTIER (~1 Mo pour ~260 icônes) alors qu'un jeu n'en utilise + typiquement que quelques-unes.""" + slugs = set() + + def scan(elements): + for el in elements or []: + if el.get("widget") == "icone": + slugs.add((el.get("attributes") or {}).get("_icon_slug") or "star") + + for s in payload.get("screens", []): + scan(s.get("elements")) + for tree in (payload.get("element_type_templates") or {}).values(): + scan(tree.get("top")) + for kids in (tree.get("children_map") or {}).values(): + scan(kids) + return slugs + + +def _build_icon_data_uris(slugs): + """Encode chaque icône RÉELLEMENT utilisée (voir _collect_icon_slugs) + en data: URI. Nécessaire en plus de _relativize_absolute_urls : Firefox + traite CHAQUE ressource file:// comme une origine opaque distincte, et + mask-image y déclenche un fetch en mode CORS même pour un chemin + relatif par ailleurs tout à fait valide ("Blocage d'une requête + multiorigine... la requête CORS n'utilise pas HTTP", signalé par un + utilisateur MALGRÉ le chemin déjà corrigé) — aucun chemin de fichier, + aussi correct soit-il, ne peut donc fonctionner sous file://. Une + data: URI n'est PAS une requête réseau séparée : elle contourne + entièrement ce problème (comme pour toute autre resource CSS + référencée par chemin sous file://).""" + uris = {} + icons_dir = os.path.join(_BASE_DIR, "static", "icons") + for icon_slug in slugs: + path = os.path.join(icons_dir, f"{icon_slug}.svg") + if not os.path.isfile(path): + continue + with open(path, "rb") as f: + b64 = base64.b64encode(f.read()).decode("ascii") + uris[icon_slug] = f"data:image/svg+xml;base64,{b64}" + return uris + + +def _inline_icon_urls(html, icon_data_uris): + """Remplace chaque "icons/.svg" (forme relative déjà posée par + _relativize_absolute_urls/render_icone.py) par sa data: URI — s'applique + aussi bien au rendu initial (déjà bake dans le payload JSON embarqué + dans cette même page) qu'à toute régénération faite plus tard côté JS + (voir forgeRenderIcone, qui lit directement gameData.icon_data_uris + plutôt que de reconstruire un chemin de fichier).""" + for icon_slug, uri in icon_data_uris.items(): + html = html.replace(f"icons/{icon_slug}.svg", uri) + return html + + def _list_package_files(staging_dir): """Chemins relatifs de tout ce qui a déjà été écrit dans staging_dir (hors imsmanifest.xml lui-même) — le manifeste doit les lister TOUS @@ -119,6 +180,11 @@ def build_scorm_zip(slug): # seule personne à la fois sur son poste, comme /game//play — # jamais d'état "par joueur" à distinguer hors ligne. payload = screens.full_game_payload(slug) + # gameData.icon_data_uris (voir forgeRenderIcone, + # static/js/play/offline/render-special-widgets.js) : ajouté au + # payload AVANT le rendu pour qu'il fasse partie du même payload_json + # embarqué, sans passe supplémentaire. + payload["icon_data_uris"] = _build_icon_data_uris(_collect_icon_slugs(payload)) staging_dir = tempfile.mkdtemp(prefix="forge_scorm_") try: @@ -130,6 +196,7 @@ def build_scorm_zip(slug): offline_mode=True, asset_url=lambda filename: "static/" + filename, ) html = _relativize_absolute_urls(html, slug) + html = _inline_icon_urls(html, payload["icon_data_uris"]) with open(os.path.join(staging_dir, "index.html"), "w", encoding="utf-8") as f: f.write(html) diff --git a/static/js/play/offline/__tests__/render-special-widgets.test.js b/static/js/play/offline/__tests__/render-special-widgets.test.js index ac04a7ed..0a8acc62 100644 --- a/static/js/play/offline/__tests__/render-special-widgets.test.js +++ b/static/js/play/offline/__tests__/render-special-widgets.test.js @@ -58,6 +58,21 @@ test('icône — repli sur "star" si _icon_slug absent', () => { assert.ok(html.includes("--icon-url:url('icons/star.svg')")); }); +// Régression signalée par un utilisateur (deux fois) : un chemin de +// fichier pour --icon-url, même corrigé pour être relatif à +// static/style.css (là où mask-image:var(--icon-url) le consomme), reste +// bloqué par CORS sous Firefox+file:// (chaque ressource file:// y est une +// origine opaque distincte). gameData.icon_data_uris (pré-encodé à +// l'export, voir build_scorm_package.py::_build_icon_data_uris) contourne +// entièrement le problème : aucune requête réseau séparée pour une data: URI. +test('icône — utilise gameData.icon_data_uris quand pré-encodée (contourne le blocage CORS de Firefox sous file://)', () => { + const el = { id: 6, widget: 'icone', attributes: { _icon_slug: 'envelope' }, style: {} }; + const gd = baseGameData(); + gd.icon_data_uris = { envelope: 'data:image/svg+xml;base64,QUJD' }; + const html = forgeRenderElementHtml(el, gd, {}, {}); + assert.ok(html.includes("--icon-url:url('data:image/svg+xml;base64,QUJD')")); +}); + test('onglets — un panneau actif, les autres masqués', () => { const parent = { id: 7, widget: 'onglets', attributes: {}, style: {} }; const tab1 = { id: 8, name: 'Infos', widget: 'conteneur', content: '', attributes: {}, style: {}, parent_id: 7 }; diff --git a/static/js/play/offline/render-special-widgets.js b/static/js/play/offline/render-special-widgets.js index d2c54cea..2c33abd2 100644 --- a/static/js/play/offline/render-special-widgets.js +++ b/static/js/play/offline/render-special-widgets.js @@ -73,17 +73,18 @@ function forgeRenderIcone(el, meta, gameData, childrenMap, ctx, parentFlexDirect var existingClass = attrs.class || ""; delete attrs.class; attrs.class = (existingClass + " icon-svg").trim(); - // Chemin RELATIF, et surtout PAS préfixé "static/" : cette URL est - // posée dans --icon-url (style inline) mais CONSOMMÉE par `mask-image: - // var(--icon-url)` dans static/style.css (voir .icon-svg) — une URL - // relative dans une propriété personnalisée CSS se résout par rapport à - // LA FEUILLE DE STYLE où le var() est utilisé (static/style.css), pas - // où elle est définie (piège CSS connu) : "static/icons/x.svg" y - // redeviendrait "static/static/icons/x.svg" (404/CORS bloqué — bug - // signalé). "icons/x.svg" est correct une fois résolu depuis - // static/style.css (voir aussi _relativize_absolute_urls dans - // publish/build_scorm_package.py, même correctif côté rendu initial). - var iconUrl = "icons/" + iconSlug + ".svg"; + // data: URI pré-calculée à l'export (voir gameData.icon_data_uris, + // publish/build_scorm_package.py::_build_icon_data_uris) — jamais un + // chemin de fichier, même relatif : Firefox traite chaque ressource + // file:// comme une origine opaque distincte, et mask-image y déclenche + // un fetch en mode CORS quel que soit le chemin ("Blocage d'une requête + // multiorigine... la requête CORS n'utilise pas HTTP", signalé par un + // utilisateur MALGRÉ un chemin relatif par ailleurs correct). Repli sur + // un chemin relatif si jamais l'icône n'a pas été pré-encodée (ne + // devrait pas arriver : _collect_icon_slugs scanne tous les widgets + // "icone", posés directement ou nichés dans un élément de jeu + // réutilisable) — au moins un lien exploitable plutôt qu'un vide. + var iconUrl = (gameData.icon_data_uris && gameData.icon_data_uris[iconSlug]) || ("icons/" + iconSlug + ".svg"); var style = forgeStyleString(el, parentFlexDirection, el.widget) + " --icon-url:url('" + iconUrl + "');"; return ""; } diff --git a/tests/test_export_scorm.py b/tests/test_export_scorm.py index a9f2f51c..ee399284 100644 --- a/tests/test_export_scorm.py +++ b/tests/test_export_scorm.py @@ -50,25 +50,26 @@ def test_export_scorm_index_html_is_offline_and_self_contained(client, game): assert "let gameData = " in html -def test_export_scorm_icon_widget_uses_a_relative_url(client, game): - """Régression signalée par un utilisateur : render_icone.py construit - son URL en dur avec un préfixe "/static/..." (correct EN LIGNE, où il - part de la racine du serveur Flask) — jamais réécrit par le refactor - asset_url de templates/play.html (celui-ci ne touche que les