From 7bc4c89eaa0421e2c03879a51a66c33ec1a9531d Mon Sep 17 00:00:00 2001 From: william Date: Wed, 2 Sep 2026 09:31:44 +0200 Subject: [PATCH] =?UTF-8?q?Export=20Web/SCORM=20:=20corrige=20les=20URLs?= =?UTF-8?q?=20absolues=20cass=C3=A9es=20en=20local=20(file://)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug signalé : icône disparue et texte suivant du dialogue absent après export. render_icone.py (et son miroir JS, forgeRenderIcone) bakaient une URL "/static/icons/..." en dur, correcte en ligne (racine du serveur Flask) mais bloquée par CORS une fois ouverte en local (file:///static/...). Même souci pour les fichiers envoyés par le créateur (/game//uploads/..., jamais dans static/, jamais copiés par le paquet). Ajoute _relativize_absolute_urls (réécriture globale post-rendu du HTML) + copie du dossier uploads/, et relativise le port JS de l'icône pour toute régénération après action en mémoire. Co-Authored-By: Claude Sonnet 5 --- publish/build_scorm_package.py | 32 +++++++++++++++++++ .../__tests__/render-special-widgets.test.js | 2 +- .../js/play/offline/render-special-widgets.js | 8 ++++- tests/test_export_scorm.py | 26 +++++++++++++++ 4 files changed, 66 insertions(+), 2 deletions(-) diff --git a/publish/build_scorm_package.py b/publish/build_scorm_package.py index 1d956c24..76c9cb32 100644 --- a/publish/build_scorm_package.py +++ b/publish/build_scorm_package.py @@ -44,6 +44,36 @@ def _copy_static_assets(staging_dir, slug): shutil.copy2(src, dst) +def _copy_uploads(staging_dir, slug): + """Fichiers envoyés par le créateur (widget Image/Vidéo, "Envoyer un + fichier" — voir routes/uploads/upload_file.py) : servis EN LIGNE par + une route Flask (/game//uploads/, jamais static/), donc + jamais copiés par _copy_static_assets ci-dessus. Absents s'il n'y en a + aucun (os.path.isdir garde-fou).""" + src = os.path.join(db.game_dir(slug), "uploads") + if os.path.isdir(src): + shutil.copytree(src, os.path.join(staging_dir, "uploads")) + + +def _relativize_absolute_urls(html, slug): + """screens/rendering (render_icone.py) et screens/labels (sprite_ + library.py/animal_sprite_library.py) construisent certaines URLs en + DUR avec un préfixe absolu "/static/..." ou "/game//uploads/..." + (corrects EN LIGNE, où ils partent de la racine du serveur Flask) — + déjà bakées dans le HTML/JSON rendu à ce stade, impossible à + distinguer d'un bête texte sans réécrire chaque fonction de rendu une + à une. Un remplacement global est plus sûr : ces deux préfixes précis + (le second inclut le slug du jeu) n'ont aucune raison d'apparaître + ailleurs que dans une URL réellement générée par le moteur. Sans ce + correctif, un index.html ouvert en local (file://) échouait à charger + icônes/sprites/fichiers envoyés (CORS bloque une requête absolue vers + file:///static/... — "la requête CORS n'utilise pas HTTP").""" + return ( + html.replace(f"/game/{slug}/uploads/", "uploads/") + .replace("/static/", "static/") + ) + + 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 @@ -81,11 +111,13 @@ def build_scorm_zip(slug): staging_dir = tempfile.mkdtemp(prefix="forge_scorm_") try: _copy_static_assets(staging_dir, slug) + _copy_uploads(staging_dir, slug) html = render_template( "play.html", game=game, screens_data=payload["screens"], payload_json=json.dumps(payload), offline_mode=True, asset_url=lambda filename: "static/" + filename, ) + html = _relativize_absolute_urls(html, slug) 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 4b72c552..55872594 100644 --- a/static/js/play/offline/__tests__/render-special-widgets.test.js +++ b/static/js/play/offline/__tests__/render-special-widgets.test.js @@ -55,7 +55,7 @@ test('icône — repli sur "star" si _icon_slug absent', () => { const html = forgeRenderElementHtml(el, baseGameData(), {}, {}); // le style entier passe par forgeHtmlEscape (comme html.escape() côté // serveur) : l'apostrophe de url('...') devient '. - assert.ok(html.includes("--icon-url:url('/static/icons/star.svg')")); + assert.ok(html.includes("--icon-url:url('static/icons/star.svg')")); }); test('onglets — un panneau actif, les autres masqués', () => { diff --git a/static/js/play/offline/render-special-widgets.js b/static/js/play/offline/render-special-widgets.js index d2d0d004..e82f5762 100644 --- a/static/js/play/offline/render-special-widgets.js +++ b/static/js/play/offline/render-special-widgets.js @@ -73,7 +73,13 @@ function forgeRenderIcone(el, meta, gameData, childrenMap, ctx, parentFlexDirect var existingClass = attrs.class || ""; delete attrs.class; attrs.class = (existingClass + " icon-svg").trim(); - var iconUrl = "/static/icons/" + iconSlug + ".svg"; + // Chemin RELATIF (jamais "/static/...") : ce fichier ne s'exécute que + // dans le paquet exporté (window.FORGE_OFFLINE, voir render-element.js), + // ouvert en local (file://) sans racine de serveur — un chemin absolu y + // déclenche un blocage CORS ("la requête CORS n'utilise pas HTTP"), + // exactement le bug qui affectait aussi le rendu initial côté serveur + // (voir _relativize_absolute_urls dans publish/build_scorm_package.py). + var iconUrl = "static/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 7f46d9e2..46e0b7b4 100644 --- a/tests/test_export_scorm.py +++ b/tests/test_export_scorm.py @@ -50,6 +50,32 @@ 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