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
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/<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):
"""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/<slug>/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)