Dev #10

Merged
w-vandal merged 22 commits from dev into main 2026-09-02 19:17:50 +00:00
4 changed files with 125 additions and 41 deletions
Showing only changes of commit 82d00b3800 - Show all commits
+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)
@@ -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;)"));
});
// 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', () => {
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
View File
@@ -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(&#x27;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):