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
|
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('icons/star.svg')"));
|
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', () => {
|
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
@@ -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('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):
|
||||||
|
|||||||
Reference in New Issue
Block a user