Export Web/SCORM : inline les icônes en data: URI (CORS Firefox/file://)
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:
co-authored by
Claude Sonnet 5
parent
b60fe63a54
commit
82d00b3800
@@ -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)
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 "<i " + forgeAttrString(attrs, style) + "></i>";
|
||||
}
|
||||
|
||||
+23
-22
@@ -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 <script>/
|
||||
<link> du template, pas le rendered_html déjà bake dans le payload).
|
||||
|
||||
Cette URL, posée en style INLINE (--icon-url), est 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é, pas où elle est
|
||||
définie (piège CSS connu) : un premier correctif ("static/icons/x.svg")
|
||||
redevenait donc "static/static/icons/x.svg" une fois résolu depuis
|
||||
static/style.css (CORS bloqué sur ce chemin doublé — deuxième rapport
|
||||
du même utilisateur) — seul "icons/x.svg" (sans "static/") est correct
|
||||
une fois résolu depuis static/style.css. Voir aussi le miroir JS
|
||||
(forgeRenderIcone, 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."""
|
||||
def test_export_scorm_icon_widget_is_inlined_as_a_data_uri(client, game):
|
||||
"""Régression signalée par un utilisateur (trois fois de suite) :
|
||||
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). Cette
|
||||
URL, posée en style INLINE (--icon-url), est CONSOMMÉE par `mask-image:
|
||||
var(--icon-url)` dans static/style.css (voir .icon-svg) :
|
||||
- un premier correctif ("static/icons/x.svg") ignorait qu'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) — devenait donc
|
||||
"static/static/icons/x.svg" ;
|
||||
- un second correctif ("icons/x.svg", sans "static/") réglait bien CE
|
||||
chemin, 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).
|
||||
Seule une data: URI (aucune requête réseau séparée) contourne
|
||||
entièrement le problème — voir _build_icon_data_uris
|
||||
(publish/build_scorm_package.py) et son miroir JS (forgeRenderIcone,
|
||||
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
|
||||
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))
|
||||
@@ -79,9 +80,9 @@ def test_export_scorm_icon_widget_uses_a_relative_url(client, game):
|
||||
import io
|
||||
with zipfile.ZipFile(io.BytesIO(resp.data)) as zf:
|
||||
html = zf.read("index.html").decode("utf-8")
|
||||
assert "icons/star.svg" in html
|
||||
assert "static/icons/star.svg" not in html
|
||||
assert "/static/icons/star.svg" not in html
|
||||
assert "--icon-url:url('data:image/svg+xml;base64," in html
|
||||
assert "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):
|
||||
|
||||
Reference in New Issue
Block a user