Export Web/SCORM : inline les icônes en data: URI (CORS Firefox/file://)
Build and deploy / test-python (push) Successful in 6m16s
Build and deploy / test-js (push) Successful in 54s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped

Le chemin relatif ("icons/x.svg") était enfin correct, mais Firefox
bloque encore par CORS TOUT chemin de fichier pour mask-image sous
file:// — chaque ressource file:// y est une origine opaque distincte,
indépendamment du chemin (troisième rapport du même utilisateur). Seule
une data: URI (aucune requête réseau séparée) contourne le problème.
_build_icon_data_uris (build_scorm_package.py) encode chaque icône
RÉELLEMENT posée dans le jeu (élément direct ou niché dans un élément de
jeu réutilisable) en base64, ajouté au payload sous
gameData.icon_data_uris ; forgeRenderIcone (JS) le consulte pour toute
régénération après une action, avec repli sur l'ancien chemin relatif
si jamais une icône n'a pas été pré-encodée.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
william
2026-09-02 10:27:04 +02:00
co-authored by Claude Sonnet 5
parent b60fe63a54
commit 82d00b3800
4 changed files with 125 additions and 41 deletions
+75 -8
View File
@@ -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 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 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.""" sélective) — réutilisés depuis build_package plutôt que dupliqués."""
import base64
import json import json
import os import os
import shutil 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 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: un style INLINE (--icon-url), mais CONSUMÉE par `mask-image:
var(--icon-url)` dans static/style.css (voir .icon-svg) — une URL var(--icon-url)` dans static/style.css (voir .icon-svg) — un chemin
relative dans une PROPRIÉTÉ PERSONNALISÉE CSS se résout par rapport à RELATIF n'y suffit PAS (voir _build_icon_data_uris plus bas, qui
la feuille de style où le var() est UTILISÉ, pas où elle est définie remplace ces chemins par des data: URI juste après cet appel) : Firefox
(piège CSS connu). "icons/x.svg" (juste "/static/" retiré, sans le traite chaque ressource file:// comme une origine opaque distincte, et
garder) reste donc correct une fois résolu depuis static/style.css, mask-image y déclenche un fetch en mode CORS quel que soit le chemin.
alors que "static/icons/x.svg" y redevient "static/static/icons/x.svg" "icons/x.svg" reste néanmoins la forme intermédiaire correcte que
(bug signalé — CORS bloqué sur ce chemin doublé). Traité EN PREMIER, _build_icon_data_uris recherche pour remplacer (elle-même résolue par
plus spécifique que le remplacement générique juste après.""" rapport à static/style.css, jamais index.html — piège CSS connu :
"static/icons/x.svg" y redeviendrait "static/static/icons/x.svg")."""
return ( return (
html.replace(f"/game/{slug}/uploads/", "uploads/") html.replace(f"/game/{slug}/uploads/", "uploads/")
.replace("/static/icons/", "icons/") .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/<slug>.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): def _list_package_files(staging_dir):
"""Chemins relatifs de tout ce qui a déjà été écrit dans 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 (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/<slug>/play — # seule personne à la fois sur son poste, comme /game/<slug>/play —
# jamais d'état "par joueur" à distinguer hors ligne. # jamais d'état "par joueur" à distinguer hors ligne.
payload = screens.full_game_payload(slug) 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_") staging_dir = tempfile.mkdtemp(prefix="forge_scorm_")
try: try:
@@ -130,6 +196,7 @@ def build_scorm_zip(slug):
offline_mode=True, asset_url=lambda filename: "static/" + filename, offline_mode=True, asset_url=lambda filename: "static/" + filename,
) )
html = _relativize_absolute_urls(html, slug) 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: with open(os.path.join(staging_dir, "index.html"), "w", encoding="utf-8") as f:
f.write(html) f.write(html)
@@ -58,6 +58,21 @@ test('icône — repli sur "star" si _icon_slug absent', () => {
assert.ok(html.includes("--icon-url:url(&#x27;icons/star.svg&#x27;)")); assert.ok(html.includes("--icon-url:url(&#x27;icons/star.svg&#x27;)"));
}); });
// 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(&#x27;data:image/svg+xml;base64,QUJD&#x27;)"));
});
test('onglets — un panneau actif, les autres masqués', () => { test('onglets — un panneau actif, les autres masqués', () => {
const parent = { id: 7, widget: 'onglets', attributes: {}, style: {} }; const parent = { id: 7, widget: 'onglets', attributes: {}, style: {} };
const tab1 = { id: 8, name: 'Infos', widget: 'conteneur', content: '', attributes: {}, style: {}, parent_id: 7 }; const tab1 = { id: 8, name: 'Infos', widget: 'conteneur', content: '', attributes: {}, style: {}, parent_id: 7 };
@@ -73,17 +73,18 @@ function forgeRenderIcone(el, meta, gameData, childrenMap, ctx, parentFlexDirect
var existingClass = attrs.class || ""; var existingClass = attrs.class || "";
delete attrs.class; delete attrs.class;
attrs.class = (existingClass + " icon-svg").trim(); attrs.class = (existingClass + " icon-svg").trim();
// Chemin RELATIF, et surtout PAS préfixé "static/" : cette URL est // data: URI pré-calculée à l'export (voir gameData.icon_data_uris,
// posée dans --icon-url (style inline) mais CONSOMMÉE par `mask-image: // publish/build_scorm_package.py::_build_icon_data_uris) — jamais un
// var(--icon-url)` dans static/style.css (voir .icon-svg) — une URL // chemin de fichier, même relatif : Firefox traite chaque ressource
// relative dans une propriété personnalisée CSS se résout par rapport à // file:// comme une origine opaque distincte, et mask-image y déclenche
// LA FEUILLE DE STYLE où le var() est utilisé (static/style.css), pas // un fetch en mode CORS quel que soit le chemin ("Blocage d'une requête
// où elle est définie (piège CSS connu) : "static/icons/x.svg" y // multiorigine... la requête CORS n'utilise pas HTTP", signalé par un
// redeviendrait "static/static/icons/x.svg" (404/CORS bloqué — bug // utilisateur MALGRÉ un chemin relatif par ailleurs correct). Repli sur
// signalé). "icons/x.svg" est correct une fois résolu depuis // un chemin relatif si jamais l'icône n'a pas été pré-encodée (ne
// static/style.css (voir aussi _relativize_absolute_urls dans // devrait pas arriver : _collect_icon_slugs scanne tous les widgets
// publish/build_scorm_package.py, même correctif côté rendu initial). // "icone", posés directement ou nichés dans un élément de jeu
var iconUrl = "icons/" + iconSlug + ".svg"; // 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 + "');"; var style = forgeStyleString(el, parentFlexDirection, el.widget) + " --icon-url:url('" + iconUrl + "');";
return "<i " + forgeAttrString(attrs, style) + "></i>"; return "<i " + forgeAttrString(attrs, style) + "></i>";
} }
+23 -22
View File
@@ -50,25 +50,26 @@ def test_export_scorm_index_html_is_offline_and_self_contained(client, game):
assert "let gameData = " in html assert "let gameData = " in html
def test_export_scorm_icon_widget_uses_a_relative_url(client, game): def test_export_scorm_icon_widget_is_inlined_as_a_data_uri(client, game):
"""Régression signalée par un utilisateur : render_icone.py construit """Régression signalée par un utilisateur (trois fois de suite) :
son URL en dur avec un préfixe "/static/..." (correct EN LIGNE, où il render_icone.py construit son URL en dur avec un préfixe "/static/..."
part de la racine du serveur Flask) — jamais réécrit par le refactor (correct EN LIGNE, où il part de la racine du serveur Flask). Cette
asset_url de templates/play.html (celui-ci ne touche que les <script>/ URL, posée en style INLINE (--icon-url), est CONSOMMÉE par `mask-image:
<link> du template, pas le rendered_html déjà bake dans le payload). var(--icon-url)` dans static/style.css (voir .icon-svg) :
- un premier correctif ("static/icons/x.svg") ignorait qu'une URL
Cette URL, posée en style INLINE (--icon-url), est CONSOMMÉE par relative dans une propriété personnalisée CSS se résout par rapport
`mask-image: var(--icon-url)` dans static/style.css (voir .icon-svg) — à LA FEUILLE DE STYLE où le var() est utilisé (static/style.css),
une URL relative dans une propriété personnalisée CSS se résout par pas où elle est définie (piège CSS connu) — devenait donc
rapport à LA FEUILLE DE STYLE où le var() est utilisé, pas où elle est "static/static/icons/x.svg" ;
définie (piège CSS connu) : un premier correctif ("static/icons/x.svg") - un second correctif ("icons/x.svg", sans "static/") réglait bien CE
redevenait donc "static/static/icons/x.svg" une fois résolu depuis chemin, mais Firefox bloque encore par CORS TOUT chemin de fichier
static/style.css (CORS bloqué sur ce chemin doublé — deuxième rapport pour mask-image sous file:// (chaque ressource file:// y est une
du même utilisateur) — seul "icons/x.svg" (sans "static/") est correct origine opaque distincte, indépendamment du chemin).
une fois résolu depuis static/style.css. Voir aussi le miroir JS Seule une data: URI (aucune requête réseau séparée) contourne
(forgeRenderIcone, static/js/play/offline/render-special-widgets.js), entièrement le problème — voir _build_icon_data_uris
qui régénère ce même widget après une action, et devait être corrigé (publish/build_scorm_package.py) et son miroir JS (forgeRenderIcone,
séparément.""" static/js/play/offline/render-special-widgets.js, qui régénère ce
même widget après une action et devait être corrigé séparément)."""
import re import re
resp = client.post(f"/game/{game}/screens/new", data={"name": "Accueil"}, follow_redirects=False) resp = client.post(f"/game/{game}/screens/new", data={"name": "Accueil"}, follow_redirects=False)
screen_id = int(re.search(r"/screens/(\d+)/edit", resp.headers["Location"]).group(1)) screen_id = int(re.search(r"/screens/(\d+)/edit", resp.headers["Location"]).group(1))
@@ -79,9 +80,9 @@ def test_export_scorm_icon_widget_uses_a_relative_url(client, game):
import io import io
with zipfile.ZipFile(io.BytesIO(resp.data)) as zf: with zipfile.ZipFile(io.BytesIO(resp.data)) as zf:
html = zf.read("index.html").decode("utf-8") html = zf.read("index.html").decode("utf-8")
assert "icons/star.svg" in html assert "--icon-url:url(&#x27;data:image/svg+xml;base64," in html
assert "static/icons/star.svg" not in html assert "icons/star.svg" not in html
assert "/static/icons/star.svg" not in html assert '"icon_data_uris": {"star":' in html
def test_export_scorm_databound_text_resolves_a_relation_field(client, game): def test_export_scorm_databound_text_resolves_a_relation_field(client, game):