Audit complet de mise en forme — Image (2e élément du tableau)

Implémente toutes les options manquantes identifiées pour l'élément
Image : dimensionnement/ratio/object-fit, filtres CSS, upload de
fichier (en plus de l'URL), lien/plein écran au clic, chargement
différé, légende, et tous les attributs de boîte partagés déjà créés
pour Titre/Paragraphe (padding/margin/fond/bordure/ombre/opacité/
position du bloc).

Système de pages : un support peut désormais avoir 0 page (un nouveau
support démarre vide), suppression de toutes les pages en un clic, et
la pagination automatique insère intelligemment la nouvelle page juste
après celle qui déborde plutôt qu'en toute fin de liste.

Bugs réels trouvés et corrigés en cours de route : le style de bloc
(dont align-self) ciblait l'élément interne au lieu de son enveloppe
(légende/lien) ; une image à sa taille native pouvait déclencher une
pagination infinie ; upload/mise à jour d'attribut ne déclenchaient
jamais le contrôle de débordement.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
william
2026-09-26 12:06:44 +02:00
co-authored by Claude Sonnet 5
parent 6c7675fad0
commit 9ad50c58b8
27 changed files with 1299 additions and 174 deletions
+9 -7
View File
@@ -13,12 +13,15 @@ def create_support(name: str, owner_folder: str) -> str:
mais un schéma entièrement différent (voir document_engine/ : un mais un schéma entièrement différent (voir document_engine/ : un
support est composé de PAGES — _document_pages —, chacune portant son support est composé de PAGES — _document_pages —, chacune portant son
propre flux d'éléments via _document_elements.page_id, jamais propre flux d'éléments via _document_elements.page_id, jamais
d'écrans/objets de scène comme côté jeu). Une première page ("Page 1") d'écrans/objets de scène comme côté jeu). Créé ici SANS aucune page
est toujours créée ici : un support n'existe jamais sans au moins une (retour utilisateur : l'éditeur doit pouvoir s'ouvrir vide, "nouveau
page (voir document_engine/pages/delete_document_page.py, qui refuse projet par ex") — l'utilisateur clique "+ Ajouter une page" pour
de supprimer la dernière). owner_folder n'est jamais optionnel ici commencer (voir document_engine/pages/add_document_page.py). Aucune
(contrairement à create_game) : un support n'existe pas sans compte page n'est donc plus une garantie côté production ; seule la fixture
propriétaire.""" de test `support` (tests/conftest.py) en crée une par convénience pour
les tests qui ne portent pas sur ce cas précis. owner_folder n'est
jamais optionnel ici (contrairement à create_game) : un support
n'existe pas sans compte propriétaire."""
project_part = slugify(name) project_part = slugify(name)
base = project_part base = project_part
n = 2 n = 2
@@ -55,7 +58,6 @@ def create_support(name: str, owner_folder: str) -> str:
""" """
) )
conn.execute("INSERT INTO _meta (key, value) VALUES ('name', ?)", (name,)) conn.execute("INSERT INTO _meta (key, value) VALUES ('name', ?)", (name,))
conn.execute("INSERT INTO _document_pages (title, order_index) VALUES ('Page 1', 0)")
conn.commit() conn.commit()
conn.close() conn.close()
return slug return slug
+17 -5
View File
@@ -8,7 +8,15 @@ def ensure_document_pages_schema(slug: str) -> None:
le système de pages avait un seul document implicite ; ses éléments le système de pages avait un seul document implicite ; ses éléments
existants deviennent tous les enfants d'une page "Page 1" créée ici existants deviennent tous les enfants d'une page "Page 1" créée ici
automatiquement (comportement le plus proche de l'ancien : un seul automatiquement (comportement le plus proche de l'ancien : un seul
document visible, qui devient simplement sa première page).""" document visible, qui devient simplement sa première page).
Ne recrée PLUS "Page 1" à chaque appel dès que _document_pages est
vide (bug qui empêchait tout support d'atteindre 0 page — un support
sans aucune page est un état valide depuis le retour utilisateur
"l'éditeur doit pouvoir s'ouvrir sans aucune page"). La création
automatique de "Page 1" ne se déclenche plus qu'une seule fois, au
moment précis de cette migration historique (juste avant d'ajouter la
colonne page_id, voir plus bas) — jamais ensuite."""
conn = connect_support(slug) conn = connect_support(slug)
conn.execute( conn.execute(
""" """
@@ -20,9 +28,6 @@ def ensure_document_pages_schema(slug: str) -> None:
); );
""" """
) )
page_count = conn.execute("SELECT COUNT(*) AS n FROM _document_pages").fetchone()["n"]
if page_count == 0:
conn.execute("INSERT INTO _document_pages (title, order_index) VALUES ('Page 1', 0)")
# vertical_align : alignement vertical du contenu de la page # vertical_align : alignement vertical du contenu de la page
# (top/center/bottom — voir document_engine/pages/ # (top/center/bottom — voir document_engine/pages/
@@ -35,7 +40,14 @@ def ensure_document_pages_schema(slug: str) -> None:
cols = {r["name"] for r in conn.execute("PRAGMA table_info(_document_elements)").fetchall()} cols = {r["name"] for r in conn.execute("PRAGMA table_info(_document_elements)").fetchall()}
if "page_id" not in cols: if "page_id" not in cols:
first_page_id = conn.execute("SELECT id FROM _document_pages ORDER BY order_index LIMIT 1").fetchone()["id"] # Vrai support pré-pages : ses éléments existants (s'il y en a)
# doivent atterrir quelque part — jamais recréé une fois cette
# migration ponctuelle passée (page_id existera alors déjà).
page_row = conn.execute("SELECT id FROM _document_pages ORDER BY order_index LIMIT 1").fetchone()
if page_row is None:
conn.execute("INSERT INTO _document_pages (title, order_index) VALUES ('Page 1', 0)")
page_row = conn.execute("SELECT id FROM _document_pages ORDER BY order_index LIMIT 1").fetchone()
first_page_id = page_row["id"]
# SQLite autorise ADD COLUMN avec une valeur par défaut CONSTANTE # SQLite autorise ADD COLUMN avec une valeur par défaut CONSTANTE
# (jamais une contrainte REFERENCES ici, même pragmatisme que # (jamais une contrainte REFERENCES ici, même pragmatisme que
# db/rows/ensure_player_id_column.py : la contrainte de clé # db/rows/ensure_player_id_column.py : la contrainte de clé
+166 -1
View File
@@ -64,6 +64,12 @@ image de fond, `overflow`/`z-index`/position absolue, transitions au
survol, réglages différents par taille d'écran (responsive), arrondi survol, réglages différents par taille d'écran (responsive), arrondi
par coin (un seul rayon pour les 4 coins, comme l'étiquette). par coin (un seul rayon pour les 4 coins, comme l'étiquette).
**Changement rétroactif (au moment de l'Image, ci-dessous)** : `width`
(largeur fixe) a été ajouté à `box_style.py` partagé — Titre/Paragraphe
gagne donc aussi ce réglage a posteriori (en plus de la largeur MAXIMALE
déjà là), sans repasser par une validation dédiée puisque c'est un ajout
pur (aucun comportement existant modifié).
Bugs transversaux trouvés et corrigés pendant ce chantier (concernent Bugs transversaux trouvés et corrigés pendant ce chantier (concernent
TOUT l'éditeur, pas seulement Titre/Paragraphe) : TOUT l'éditeur, pas seulement Titre/Paragraphe) :
- Panneau Propriétés jamais reconstruit après un clic (boutons/segments - Panneau Propriétés jamais reconstruit après un clic (boutons/segments
@@ -74,7 +80,166 @@ TOUT l'éditeur, pas seulement Titre/Paragraphe) :
à tort par les cases à cocher et curseurs, cassant leur apparence à tort par les cases à cocher et curseurs, cassant leur apparence
native. native.
### 2. Image — à faire (prochain élément) ### 2. Image — ✅ audité et validé (commité)
Implémenté : légende (`caption`, enveloppe dans `<figure>/<figcaption>`),
ajustement dans son cadre (`object_fit` : taille réelle/couvrir/
contenir/étirer), format/ratio (`aspect_ratio` : libre/carré/4:3/16:9),
filtre (3 préréglages : noir et blanc/sépia/flouté), chargement différé
(`loading="lazy"`), comportement au clic mutuellement exclusif (aucun /
lien externe dans un nouvel onglet / plein écran — un vrai overlay
plein écran côté client, `forgeDocOpenImageLightbox`), tous les
attributs de boîte partagés (dont la nouvelle **largeur fixe**, ajoutée
à `box_style.py` à cette occasion).
Comme pour les mini-jeux et la pièce jointe d'un bouton, le lien et le
plein écran ne sont réellement cliquables qu'en Mode Aperçu — en
édition, le clic sélectionne l'élément (la navigation native du lien
est bloquée pour ne pas quitter l'éditeur par accident).
**Ajout (retour utilisateur : "il manque la possibilité d'uploader une
image")** : téléversement d'un fichier depuis l'ordinateur (PNG/JPG/
GIF/WEBP/SVG), en plus du champ URL externe déjà là — mirroir exact du
mécanisme déjà en place pour la pièce jointe d'un bouton :
`routes/document/document_element_upload_image.py` (stocke sous
`uploads/`, jamais `attachments/` — pas de `as_attachment`, l'image doit
s'afficher, pas se télécharger) et `routes/document/
document_uploaded_file.py` (route de service dédiée). Le fichier
téléversé vide `svg_markup` au passage (les deux modes ne coexistent
jamais). Le panneau propose maintenant les deux : sélecteur de fichier
(natif, pas de champ texte) en premier, champ URL externe en second
pour une image déjà hébergée ailleurs.
Volontairement laissé de côté (complexité/valeur douteuse pour du
contenu de formation) : `mix-blend-mode`, découpe `clip-path`, overlay
au survol, arrondi par coin, réglages responsive par taille d'écran.
Bug pré-existant corrigé au passage : `docImageSrc`/`docImageAlt`
n'étaient pas échappés avant insertion dans l'attribut `value` du
panneau Propriétés (mineur, mais corrigé puisque cette fonction était
déjà réécrite).
**Bug réel corrigé (retour utilisateur : "la position de bloc ne
fonctionne pas sur l'image")** : `render_box_style` (padding/margin/
fond/bordure/largeur/position du bloc, dont `align_self`) était
appliqué à l'`<img>`/`<div>` INTERNE, jamais à son enveloppe
(`<figure>`/`<a class="docImageLink">`/`<div class=
"docImageLightboxTrigger">`) quand une légende ou un comportement au
clic en ajoutait une — `align-self` posé sur un simple descendant du
flex-item n'a aucun effet CSS, d'où le bouton "position du bloc" sans
effet visible dès qu'une légende ou un lien était configuré. Corrigé
dans `_render_image` (`document_engine/rendering/
render_document_element.py`) : le style de bloc cible désormais
toujours l'élément réellement top-niveau (enfant direct de
`.docPageContent`), quel que soit l'emboîtement. Changement de
comportement réel sur du contenu existant : une image avec légende/lien
et un fond/une bordure/un padding déjà réglés les verra désormais
appliqués à TOUT le bloc (image + légende), pas seulement à l'image —
c'est le comportement correct/attendu, mais je le signale car ça change
le rendu visuel d'éléments déjà créés.
**Bug réel corrigé (retour utilisateur : "quand j'ajoute une image ça
crée des pages à l'infini")** : une photo importée à sa taille native
pouvait dépasser une page entière à elle seule ; la pagination
automatique (`forgeDocCheckPageOverflow`, static/document/js/
document-editor.js) la déplaçait alors sans fin vers une page neuve,
qui débordait identiquement. Corrigé une première fois par un garde-fou générique (si tout le
contenu de la page déborde déjà, rien à répartir, on arrête) + une
hauteur maximale par défaut sur `img.docImage` (plafonnée à la hauteur
intérieure de la page, `static/document/document-editor.css`).
**Affiné ensuite (retour utilisateur : "si l'image uploadée est trop
grande je préfère qu'elle soit redimensionnée plutôt que bouger sur une
autre page")** : `forgeDocCheckPageOverflow` (static/document/js/
document-editor.js) évite de paginer une image qui déborde quand il
reste assez de place pour un résultat encore utilisable — elle est
rétrécie SUR PLACE (`max-height` posé en style inline,
calculé à partir de l'espace réellement disponible sous elle sur SA
page actuelle, légende comprise) à chaque rafraîchissement du canevas.
Purement visuel, jamais persisté en attribut (l'espace disponible
dépend du contenu au-dessus, qui change en éditant). Un réglage
explicite de hauteur maximale via le panneau reste prioritaire (une
valeur inline posée par un attribut serait recalculée par-dessus à
l'affichage suivant si elle déborde encore). Le garde-fou anti-boucle
et le défaut CSS restent en place pour les AUTRES kinds et comme filet
de sécurité au tout premier rendu (avant que le JS n'ait tourné).
**Changement de conception (retour utilisateur : "ce cadre ne devrait
pas changer de taille en fonction de la taille de l'image mais être
fixe et contraindre l'image dedans")** : plutôt que de rétrécir
dynamiquement une image trop grande selon l'espace disponible (approche
fragile, source des deux bugs ci-dessus), un nouvel élément image a
maintenant un cadre FIXE dès sa création — `object_fit="cover"` +
`height="220px"` par défaut au lieu de vides (`document_engine/labels/
element_kind_labels.py`) — une photo importée est donc TOUJOURS rognée
pour remplir ce cadre, quelle que soit sa résolution native. "Taille
réelle" reste un choix explicite possible dans le panneau (segmented
"Ajustement dans son cadre"). **Ne s'applique qu'aux NOUVEAUX éléments
image** — un élément déjà créé avant ce changement garde ses attributs
`object_fit`/`height` existants (jamais re-migré automatiquement,
`sanitize_element_attributes` ne touche pas au kind "image", voir sa
docstring) ; pour en faire bénéficier une image déjà présente, régler
manuellement "Couvrir" + une hauteur via le panneau. Le rétrécissement
dynamique (garde-fou anti-boucle + `forgeDocCheckPageOverflow` appelé
après upload/mise à jour d'attribut) reste en place comme filet de
sécurité pour une hauteur explicite déraisonnablement grande.
**Bug réel corrigé une seconde fois (retour utilisateur : "ce n'est pas
redimensionner")** : le rétrécissement ci-dessus vit dans
`forgeDocCheckPageOverflow`, appelée uniquement par
`forgeDocRefreshCanvas` (ajout/déplacement/suppression/Annuler-
Rétablir) — mais **téléverser un fichier dans une image existante**
(`forgeDocApiUploadImage`) et **changer un attribut quelconque**
(`forgeDocUpdateAttributes`, le point d'entrée central de tous les
panneaux) patchent chacun le DOM directement, SANS jamais passer par
ce chemin : le rétrécissement ne se déclenchait donc jamais après un
upload. Corrigé en appelant explicitement `forgeDocCheckPageOverflow()`
à la fin de ces deux fonctions. Pas de test automatisé possible ici :
aucune suite de tests n'existe pour `document-editor.js`
(`package.json` ne couvre que `static/game/js/`), vérification
manuelle uniquement.
**Affiné une troisième fois (retour utilisateur : "l'ajout de page
quand le contenu déborde dois rester et même être plus intelligent, la
nouvelle page dois etre sous la page qui deborde meme si ya d'autre
page")** : la pagination automatique (pour tout kind, pas seulement les
images désormais épargnées ci-dessus) insérait toujours la page neuve
en toute fin de la bande d'onglets (`add_document_page` l'ajoute
toujours à la fin), même si d'autres pages existaient déjà après celle
qui déborde — déborder sur la page 2 d'un support qui en compte 5
ajoutait la nouvelle page en position 6 au lieu de 3. Corrigé dans
`forgeDocCheckPageOverflow` : la page est créée puis immédiatement
déplacée (`forgeDocApiPageMove`, mécanisme déjà existant pour le
glisser-déposer du panneau Pages) juste après la page active, décalant
les pages suivantes d'un cran. Extraction d'un helper partagé
`forgeDocReorderLocalPage` (état client après un déplacement) réutilisé
par le glisser-déposer ET par ce nouveau cas, pour ne pas dupliquer ce
calcul. Vérification côté serveur déjà couverte par les tests existants
de `move_document_page`/`document_page_move` (l'insertion "au milieu"
de la bande d'onglets y est déjà testée) ; le déclenchement côté client
reste manuel faute de suite de tests JS.
**Affiné une quatrième fois** : sous un seuil de place restante
(`FORGE_DOC_MIN_IMAGE_HEIGHT`, 60px) il n'y a plus de place RÉELLE sur
la page (pas seulement pour cette image) — l'image bascule alors dans
la pagination normale ci-dessus au lieu d'être rétrécie à une taille
inutilisable, ce qui empêchait sinon une page pleine d'images
d'enchaîner sur une nouvelle page. Vérifié par un test de bout en bout
en conditions réelles (navigateur automatisé Playwright contre le
serveur local, sur un support jetable créé puis supprimé pour
l'occasion) : 5 images vides ajoutées à la suite produisent bien 3
pages, chacune avec le cadre fixe "Couvrir" actif par défaut.
**Audit final** : chaque ligne du tableau d'audit initial pour
l'élément Image est couverte — implémentée (dimensionnement/ratio/
object-fit, bordure+ombre via `box_style.py`, filtre, lien/lightbox/
lazy-load/légende, position du bloc dont l'alignement, upload de
fichier) ou explicitement différée ci-dessus avec sa raison
(`object-position`, `mix-blend-mode`, `clip-path`, overlay au survol,
arrondi par coin, responsive par taille d'écran — mêmes exclusions que
Titre/Paragraphe, valeur douteuse pour du contenu de formation). Rien
d'oublié constaté à cette relecture. Validé par l'utilisateur, prêt à
committer.
### 3. Bouton — à faire ### 3. Bouton — à faire
+2
View File
@@ -71,6 +71,7 @@ from .labels.scenario_config import (
sanitize_scenario_config, sanitize_scenario_config,
) )
from .pages.add_document_page import add_document_page from .pages.add_document_page import add_document_page
from .pages.delete_all_document_pages import delete_all_document_pages
from .pages.delete_document_page import delete_document_page from .pages.delete_document_page import delete_document_page
from .pages.get_document_page import get_document_page from .pages.get_document_page import get_document_page
from .pages.list_document_pages import list_document_pages from .pages.list_document_pages import list_document_pages
@@ -109,6 +110,7 @@ __all__ = [
"VERTICAL_ALIGNS", "VERTICAL_ALIGNS",
"add_document_element", "add_document_element",
"add_document_page", "add_document_page",
"delete_all_document_pages",
"delete_document_element", "delete_document_element",
"delete_document_page", "delete_document_page",
"element_default_attributes", "element_default_attributes",
+42 -7
View File
@@ -48,10 +48,6 @@ _TEXT_DEFAULTS = {
"strikethrough": False, "strikethrough": False,
"align": "left", "align": "left",
"color": "var(--forge-text)", "color": "var(--forge-text)",
# Vide par défaut = pleine largeur de .docPageContent (comportement
# historique) ; une valeur CSS libre (ex. "60ch", "480px") restreint
# la largeur du bloc — voir render_document_element._render_text.
"max_width": "",
# Vides par défaut = valeurs du préréglage `style` inchangées (voir # Vides par défaut = valeurs du préréglage `style` inchangées (voir
# _STYLE_PRESETS dans render_document_element.py) — une valeur # _STYLE_PRESETS dans render_document_element.py) — une valeur
# explicite les remplace (retour utilisateur du 26/09/2026, audit des # explicite les remplace (retour utilisateur du 26/09/2026, audit des
@@ -62,8 +58,10 @@ _TEXT_DEFAULTS = {
"letter_spacing": "", "letter_spacing": "",
"text_transform": "none", "text_transform": "none",
"text_shadow": "", "text_shadow": "",
# Attributs de boîte partagés avec d'autres kinds (padding/margin/ # Attributs de boîte partagés avec d'autres kinds (dont `max_width`,
# background_color/border_radius/align_self) — voir rendering/box_style.py. # ex. "60ch"/"480px" — retour utilisateur du 24/09/2026 : un
# paragraphe doit pouvoir rester plus étroit que la page) — voir
# rendering/box_style.py.
**BOX_DEFAULTS, **BOX_DEFAULTS,
} }
@@ -88,7 +86,44 @@ def element_default_attributes(kind: str) -> dict[str, Any]:
# svg_markup (optionnel) prend le pas sur src au rendu (voir # svg_markup (optionnel) prend le pas sur src au rendu (voir
# render_document_element._render_image) — un contenu vectoriel # render_document_element._render_image) — un contenu vectoriel
# dessiné/collé directement plutôt qu'un fichier hébergé. # dessiné/collé directement plutôt qu'un fichier hébergé.
return {"src": "", "alt": "", "svg_markup": ""} # aspect_ratio/filter_preset : vides par défaut (aucun style
# ajouté). click_behavior/link_url/lazy_load/caption :
# comportement/contenu, pas du style (retour utilisateur du
# 26/09/2026, audit des réglages manquants — colonne "Image").
# click_behavior ("" | "link" | "lightbox") et link_url sont
# mutuellement dépendants (un lien sans URL ne fait rien au
# rendu, voir _render_image) mais jamais revalidés l'un par
# rapport à l'autre ici : cette combinaison reste sans risque
# quelle qu'elle soit.
#
# object_fit="cover" + height="220px" (au lieu de vides) : cadre
# FIXE par défaut (retour utilisateur du 26/09/2026 : "ce cadre
# ne devrait pas changer de taille en fonction de la taille de
# l'image mais être fixe et contraindre l'image dedans") — une
# photo importée est désormais TOUJOURS rognée pour remplir ce
# cadre, quelle que soit sa résolution native, plutôt que de
# dicter elle-même la taille du bloc. "Taille réelle" reste
# sélectionnable explicitement dans le panneau Propriétés
# (segmented "Ajustement dans son cadre") pour qui préfère
# revenir à l'ancien comportement (hauteur libre, aucun
# object-fit) ; un défaut CSS aveugle sur TOUTE image aurait
# rendu ce choix impossible à distinguer de "jamais réglé", les
# deux valant la chaîne vide.
return {
"src": "",
"alt": "",
"svg_markup": "",
"object_fit": "cover",
"aspect_ratio": "",
"filter_preset": "",
"click_behavior": "",
"link_url": "",
"lazy_load": False,
"caption": "",
**BOX_DEFAULTS,
"height": "220px",
"border": default_border(),
}
if kind == "bouton": if kind == "bouton":
# attachment_stored_name/attachment_filename (optionnels) : un # attachment_stored_name/attachment_filename (optionnels) : un
# fichier téléchargeable joint au bouton (voir routes/document/ # fichier téléchargeable joint au bouton (voir routes/document/
+18 -10
View File
@@ -44,18 +44,26 @@ l'affichage/debug hors bibliothèque).
## `element_default_attributes(kind: str) -> dict[str, Any]` ## `element_default_attributes(kind: str) -> dict[str, Any]`
Attributs posés à la création d'un élément de ce type (voir Attributs posés à la création d'un élément de ce type (voir
`document_engine/elements/add_document_element.py`). `document_engine/elements/add_document_element.py`).
- **Retour** : un dict d'attributs par défaut, dépendant du `kind` : - **Retour** : un dict d'attributs par défaut, dépendant du `kind` (les
"attributs de boîte partagés" mentionnés ci-dessous — `padding/margin/
background_color/border_radius/width/max_width/height/min_height/
max_height/min_width/box_shadow/opacity/align_self/border` — sont
toujours les mêmes, voir `rendering/box_style.py` : tous vides,
`False` ou `"none"`/`"stretch"` par défaut = comportement historique
inchangé pour le kind qui les gagne) :
texte (`content/style` + `bold/italic/underline/strikethrough/align/ texte (`content/style` + `bold/italic/underline/strikethrough/align/
color/max_width/font_family/font_size/line_height/letter_spacing/ color/font_family/font_size/line_height/letter_spacing/
text_transform/text_shadow` + les attributs de boîte partagés text_transform/text_shadow` + les attributs de boîte partagés),
`padding/margin/background_color/border_radius/height/min_height/
max_height/min_width/box_shadow/opacity/align_self/border`, voir
`rendering/box_style.py` — tous vides, `False` ou `"none"`/`"stretch"`
par défaut = comportement historique inchangé ; `max_width`
restreint la largeur du bloc, une valeur CSS libre ex. `"60ch"`/
`"480px"`, voir `rendering.md`),
image (`src/alt/svg_markup` — `svg_markup` prend le pas sur `src` au image (`src/alt/svg_markup` — `svg_markup` prend le pas sur `src` au
rendu, voir rendering.md), bouton (`label/target/ rendu, voir rendering.md — `object_fit="cover"` et `height="220px"`
par défaut (retour utilisateur du 26/09/2026 : "ce cadre ne devrait
pas changer de taille en fonction de la taille de l'image mais être
fixe et contraindre l'image dedans" — un cadre FIXE, jamais dicté par
la résolution native du fichier importé ; "Taille réelle" reste un
choix explicite possible via le panneau), `aspect_ratio/filter_preset`
vides par défaut, `click_behavior` (`""`/`"link"`/`"lightbox"`) +
`link_url` + `lazy_load` + `caption` : comportement/contenu, pas du
style + les attributs de boîte partagés), bouton (`label/target/
attachment_stored_name/attachment_filename` — la pièce jointe est attachment_stored_name/attachment_filename` — la pièce jointe est
indépendante de `target`, réservé à la navigation), liste à indépendante de `target`, réservé à la navigation), liste à
puces/numérotée (`items`, une liste de chaînes), badge puces/numérotée (`items`, une liste de chaînes), badge
@@ -0,0 +1,16 @@
from db.supports import connect_support, ensure_document_pages_schema
def delete_all_document_pages(slug: str) -> None:
"""Supprime toutes les pages d'un support d'un coup (retour
utilisateur : "une option dans page pour supprimer toute les page
d'un coup") — CASCADE (contrainte FK, voir create_support.py) retire
aussi tous les éléments de contenu du support. Résultat : un support
à 0 page, état volontairement valide (voir list_document_pages.py) ;
l'utilisateur repart d'un éditeur vide comme un support neuf."""
ensure_document_pages_schema(slug)
conn = connect_support(slug)
conn.execute("DELETE FROM _document_elements")
conn.execute("DELETE FROM _document_pages")
conn.commit()
conn.close()
+3 -2
View File
@@ -7,8 +7,9 @@ def list_document_pages(slug: str) -> list[dict[str, Any]]:
"""Toutes les pages d'un support, triées par order_index — la bande """Toutes les pages d'un support, triées par order_index — la bande
d'onglets du panneau Propriétés (voir static/document/js/ d'onglets du panneau Propriétés (voir static/document/js/
document-editor.js) et le sélecteur de page du Mode Aperçu en dérivent document-editor.js) et le sélecteur de page du Mode Aperçu en dérivent
directement. Un support a toujours au moins une page (voir directement. Peut renvoyer une liste VIDE (retour utilisateur : un
db/supports/create_support.py / ensure_document_pages_schema).""" support neuf, ou vidé via "Supprimer toutes les pages", s'ouvre sans
aucune page — voir routes/document/document_edit.py, qui gère ce cas)."""
ensure_document_pages_schema(slug) ensure_document_pages_schema(slug)
conn = connect_support(slug) conn = connect_support(slug)
rows = conn.execute("SELECT * FROM _document_pages ORDER BY order_index").fetchall() rows = conn.execute("SELECT * FROM _document_pages ORDER BY order_index").fetchall()
+28 -11
View File
@@ -4,13 +4,21 @@ CRUD des pages d'un support de formation (`_document_pages`, voir
`db/supports/create_support.py`) — retour utilisateur du 21/09/2026: `db/supports/create_support.py`) — retour utilisateur du 21/09/2026:
"il faut implémenter un système de page". Un support est désormais "il faut implémenter un système de page". Un support est désormais
composé de plusieurs pages, chacune portant son propre flux d'éléments composé de plusieurs pages, chacune portant son propre flux d'éléments
(voir `document_engine/elements/`, filtré par `page_id`). Un support a (voir `document_engine/elements/`, filtré par `page_id`).
TOUJOURS au moins une page (`create_support` en crée une par défaut,
`ensure_document_pages_schema` en garantit une pour les supports plus **Un support peut avoir 0 page** (retour utilisateur du 26/09/2026 :
anciens) — la garde "jamais supprimer la dernière page" est un "l'éditeur ne dois plus etre obliger d'avoir une page active ou créer,
garde-fou métier posé par l'appelant (voir il peut etre ouvert sans aucune page") — `create_support` n'en crée plus
`routes/document/document_page_delete.py`), pas une contrainte de ce aucune par défaut, et `ensure_document_pages_schema` ne recrée plus
paquet. "Page 1" dès que la table est vide (seule exception : la migration
ponctuelle et historique d'un support pré-pages qui avait déjà des
éléments sans `page_id`). `routes/document/document_edit.py` et le
frontend (`static/document/js/document-editor.js`) gèrent explicitement
cet état "aucune page" (pas de page active, canevas vide avec une
invite à en créer une). La garde "jamais supprimer la dernière page" a
été retirée du côté route (voir `delete_all_document_pages` ci-dessous
et `routes/document/document_page_delete.py`) : ce paquet n'a jamais
posé cette contrainte lui-même.
## `add_document_page(slug: str, title: str | None = None) -> int` ## `add_document_page(slug: str, title: str | None = None) -> int`
Ajoute une page en fin de la bande d'onglets. `title` par défaut : Ajoute une page en fin de la bande d'onglets. `title` par défaut :
@@ -87,9 +95,18 @@ Supprime une page ET ses éléments (`DELETE FROM _document_elements
WHERE page_id = ?` explicite — la contrainte `FOREIGN KEY ... ON DELETE WHERE page_id = ?` explicite — la contrainte `FOREIGN KEY ... ON DELETE
CASCADE` n'existe que pour les supports créés après l'ajout des pages, CASCADE` n'existe que pour les supports créés après l'ajout des pages,
voir `db/supports/ensure_document_pages_schema.py` pour les anciens). voir `db/supports/ensure_document_pages_schema.py` pour les anciens).
Ne refuse JAMAIS de supprimer la dernière page restante — cette règle Ne refuse JAMAIS de supprimer la dernière page restante — un support à
est posée par l'appelant, pas par cette fonction bas niveau (même 0 page est un état valide (voir plus haut).
découpage que `routes/game/screens/screen_delete.py` côté jeu, où le - **Retour** : aucun.
garde-fou vit aussi dans la route). - **Exceptions** : aucune.
## `delete_all_document_pages(slug: str) -> None`
Supprime TOUTES les pages du support d'un coup, et tous leurs éléments
de contenu avec elles (retour utilisateur : "une option dans page pour
supprimer toute les page d'un coup") — action destructive et
irréversible côté serveur, jamais appelée sans confirmation explicite
côté client (voir `static/document/js/document-editor.js`,
`forgeDocDeleteAllPages`). Le support se retrouve à 0 page, exactement
comme un support neuf.
- **Retour** : aucun. - **Retour** : aucun.
- **Exceptions** : aucune. - **Exceptions** : aucune.
@@ -29,11 +29,10 @@ def replace_document_content(slug: str, seed_pages: list[dict[str, Any]]) -> Non
element_default_attributes(kind) (jamais un remplacement brut) pour element_default_attributes(kind) (jamais un remplacement brut) pour
rester valides même si `seed_pages` n'en précise qu'une partie.""" rester valides même si `seed_pages` n'en précise qu'une partie."""
# Les nouvelles pages sont créées AVANT de supprimer les anciennes # Les nouvelles pages sont créées AVANT de supprimer les anciennes
# (jamais l'inverse) : passer par zéro page, même un court instant, # (jamais l'inverse) : ça évite que le support affiche un état "0 page"
# déclenche le filet de sécurité de ensure_document_pages_schema (un # transitoire pendant le remplacement (même si 0 page est désormais un
# support a TOUJOURS au moins une page), qui recréerait une "Page 1" # état par ailleurs valide, voir list_document_pages.py — ce n'est
# vide juste avant l'ajout de la première page du thème — bug réel # qu'une question d'ordre d'écriture ici, plus un filet de sécurité).
# rencontré en écrivant les tests de cette fonction.
old_page_ids = [page["id"] for page in list_document_pages(slug)] old_page_ids = [page["id"] for page in list_document_pages(slug)]
for seed_page in seed_pages: for seed_page in seed_pages:
page_id = add_document_page(slug) page_id = add_document_page(slug)
+45 -48
View File
@@ -27,6 +27,8 @@ BOX_DEFAULTS = {
"background_color": "", "background_color": "",
"border_radius": "", "border_radius": "",
"align_self": "stretch", "align_self": "stretch",
"width": "",
"max_width": "",
"height": "", "height": "",
"min_height": "", "min_height": "",
"max_height": "", "max_height": "",
@@ -36,58 +38,39 @@ BOX_DEFAULTS = {
} }
def render_box_style(a: dict[str, Any]) -> str: # (clé d'attribut, propriété CSS) — chaque paire suit exactement le même
"""Construit les déclarations CSS inline communes à plusieurs kinds à # patron (lire/nettoyer/ajouter si non vide) ; une simple table de
partir des attributs `padding`/`margin`/`background_color`/ # correspondance ici évite un enchaînement de blocs `if` identiques
`border_radius`/`height`/`min_height`/`max_height`/`min_width`/ # (complexité cognitive réduite, voir _render_simple_properties).
`box_shadow`/`opacity`/`border`/`align_self` de `a` — chaîne vide pour _SIMPLE_PROPERTIES = (
tout attribut absent ou à sa valeur par défaut (aucun style ajouté, ("padding", "padding"),
comportement historique inchangé). `border` est un dict à 4 clés ("margin", "margin"),
(`BORDER_SIDES`), chacune `{"style", "width", "color"}` — un côté à ("background_color", "background-color"),
`style="none"` (ou absent) ne produit aucune déclaration pour ce ("border_radius", "border-radius"),
côté, jamais un `border-top:none` explicite. ("width", "width"),
- **Retour** : les déclarations CSS (`"propriete:valeur; ..."`), ("max_width", "max-width"),
jamais vide ni `None`. ("height", "height"),
- **Exceptions** : aucune.""" ("min_height", "min-height"),
parts: list[str] = [] ("max_height", "max-height"),
("min_width", "min-width"),
("box_shadow", "box-shadow"),
("opacity", "opacity"),
)
padding = str(a.get("padding", "")).strip()
if padding:
parts.append(f"padding:{html_lib.escape(padding)};")
margin = str(a.get("margin", "")).strip() def _render_simple_properties(a: dict[str, Any]) -> list[str]:
if margin: parts = []
parts.append(f"margin:{html_lib.escape(margin)};") for attr_key, css_prop in _SIMPLE_PROPERTIES:
value = str(a.get(attr_key, "")).strip()
if value:
parts.append(f"{css_prop}:{html_lib.escape(value)};")
return parts
background_color = str(a.get("background_color", "")).strip()
if background_color:
parts.append(f"background-color:{html_lib.escape(background_color)};")
border_radius = str(a.get("border_radius", "")).strip()
if border_radius:
parts.append(f"border-radius:{html_lib.escape(border_radius)};")
height = str(a.get("height", "")).strip()
if height:
parts.append(f"height:{html_lib.escape(height)};")
min_height = str(a.get("min_height", "")).strip()
if min_height:
parts.append(f"min-height:{html_lib.escape(min_height)};")
max_height = str(a.get("max_height", "")).strip()
if max_height:
parts.append(f"max-height:{html_lib.escape(max_height)};")
min_width = str(a.get("min_width", "")).strip()
if min_width:
parts.append(f"min-width:{html_lib.escape(min_width)};")
box_shadow = str(a.get("box_shadow", "")).strip()
if box_shadow:
parts.append(f"box-shadow:{html_lib.escape(box_shadow)};")
opacity = str(a.get("opacity", "")).strip()
if opacity:
parts.append(f"opacity:{html_lib.escape(opacity)};")
def _render_border(a: dict[str, Any]) -> list[str]:
"""Un côté à `style="none"` (ou absent) ne produit aucune déclaration
pour ce côté, jamais un `border-top:none` explicite."""
parts = []
border = a.get("border") or {} border = a.get("border") or {}
for side in BORDER_SIDES: for side in BORDER_SIDES:
side_border = border.get(side) or {} side_border = border.get(side) or {}
@@ -96,6 +79,20 @@ def render_box_style(a: dict[str, Any]) -> str:
width = html_lib.escape(str(side_border.get("width", "1px"))) width = html_lib.escape(str(side_border.get("width", "1px")))
color = html_lib.escape(str(side_border.get("color", "var(--doc-border)"))) color = html_lib.escape(str(side_border.get("color", "var(--doc-border)")))
parts.append(f"border-{side}:{width} {html_lib.escape(style)} {color};") parts.append(f"border-{side}:{width} {html_lib.escape(style)} {color};")
return parts
def render_box_style(a: dict[str, Any]) -> str:
"""Construit les déclarations CSS inline communes à plusieurs kinds à
partir des attributs listés dans `_SIMPLE_PROPERTIES` + `border`/
`align_self` de `a` — chaîne vide pour tout attribut absent ou à sa
valeur par défaut (aucun style ajouté, comportement historique
inchangé). `border` est un dict à 4 clés (`BORDER_SIDES`), chacune
`{"style", "width", "color"}`.
- **Retour** : les déclarations CSS (`"propriete:valeur; ..."`),
jamais vide ni `None`.
- **Exceptions** : aucune."""
parts = _render_simple_properties(a) + _render_border(a)
# align-self ne fait quoi que ce soit d'utile QUE si l'élément a par # align-self ne fait quoi que ce soit d'utile QUE si l'élément a par
# ailleurs une taille bornée (max_width/width) — voir la note dans # ailleurs une taille bornée (max_width/width) — voir la note dans
@@ -94,15 +94,11 @@ def _render_text(el: dict[str, Any], _children_by_parent: dict[int | None, list[
if text_shadow: if text_shadow:
style += f" text-shadow:{html_lib.escape(text_shadow)};" style += f" text-shadow:{html_lib.escape(text_shadow)};"
# max_width optionnel (ex. "60ch", "480px") — vide par défaut (pleine # max_width (ex. "60ch", "480px" — retour utilisateur du 24/09/2026 :
# largeur de .docPageContent, comportement inchangé). Retour # un paragraphe doit pouvoir rester plus étroit que la page, sans
# utilisateur du 24/09/2026 : un paragraphe doit pouvoir rester plus # dépendre d'une rangée qui en partagerait la largeur avec un frère)
# étroit que la page, comme un sous-titre sous un grand titre, sans # fait maintenant partie des attributs de boîte partagés
# dépendre d'une rangée (qui partagerait la largeur avec un frère). # (render_box_style), jamais géré ici en double.
max_width = str(a.get("max_width", "")).strip()
if max_width:
style += f" max-width:{html_lib.escape(max_width)};"
box_style = render_box_style(a) box_style = render_box_style(a)
if box_style: if box_style:
style += f" {box_style}" style += f" {box_style}"
@@ -110,8 +106,53 @@ def _render_text(el: dict[str, Any], _children_by_parent: dict[int | None, list[
return f'<div class="docText" data-element-id="{el["id"]}" data-kind="{el["kind"]}" style="{style}">{content}</div>' return f'<div class="docText" data-element-id="{el["id"]}" data-kind="{el["kind"]}" style="{style}">{content}</div>'
_IMAGE_OBJECT_FITS = ("cover", "contain", "fill")
_IMAGE_FILTERS = {
"grayscale": "grayscale(1)",
"sepia": "sepia(0.8)",
"blur": "blur(3px)",
}
def _image_extra_style(a: dict[str, Any]) -> str:
"""Déclarations CSS spécifiques à l'image (`object-fit`/`aspect-ratio`/
`filter`) — jamais dans `box_style.py` (partagé), qui ne connaît que
des attributs communs à plusieurs kinds."""
parts = []
object_fit = str(a.get("object_fit", ""))
if object_fit in _IMAGE_OBJECT_FITS:
parts.append(f"object-fit:{object_fit};")
aspect_ratio = str(a.get("aspect_ratio", "")).strip()
if aspect_ratio:
parts.append(f"aspect-ratio:{html_lib.escape(aspect_ratio)};")
filter_value = _IMAGE_FILTERS.get(str(a.get("filter_preset", "")))
if filter_value:
parts.append(f"filter:{filter_value};")
return " ".join(parts)
def _render_image(el: dict[str, Any], _children_by_parent: dict[int | None, list[dict[str, Any]]]) -> str: def _render_image(el: dict[str, Any], _children_by_parent: dict[int | None, list[dict[str, Any]]]) -> str:
a = el["attributes"] a = el["attributes"]
click_behavior = str(a.get("click_behavior", ""))
link_url = str(a.get("link_url", "")).strip()
caption = str(a.get("caption", "")).strip()
# render_box_style (padding/margin/fond/bordure/largeur/position du
# bloc, dont align-self) doit se poser sur l'élément RÉELLEMENT
# top-niveau — celui qui est l'enfant direct du flex-column de la
# page (voir .docPageContent, static/document/document-editor.css) —
# jamais sur l'<img>/<div> interne dès qu'une légende ou un
# comportement au clic l'enveloppe : un align-self posé sur un
# DESCENDANT du flex-item n'a strictement aucun effet côté CSS (bug
# réel constaté le 26/09/2026 : "la position de bloc ne fonctionne
# pas sur l'image"). has_wrapper détermine qui, de l'image elle-même
# ou de son enveloppe, est ce top-niveau.
has_wrapper = bool(caption) or (click_behavior == "link" and link_url) or click_behavior == "lightbox"
box_style = render_box_style(a)
media_style = " ".join(p for p in (_image_extra_style(a), "" if has_wrapper else box_style) if p)
media_style_attr = f' style="{media_style}"' if media_style else ""
loading_attr = ' loading="lazy"' if a.get("lazy_load") else ""
svg_markup = str(a.get("svg_markup", "")).strip() svg_markup = str(a.get("svg_markup", "")).strip()
if svg_markup: if svg_markup:
# Contenu vectoriel dessiné/collé par le créateur plutôt qu'un # Contenu vectoriel dessiné/collé par le créateur plutôt qu'un
@@ -120,15 +161,51 @@ def _render_image(el: dict[str, Any], _children_by_parent: dict[int | None, list
# CHAQUE rendu (jamais seulement à l'écriture) par sanitize_svg_markup, # CHAQUE rendu (jamais seulement à l'écriture) par sanitize_svg_markup,
# même défense en profondeur que html.escape sur les autres kinds. # même défense en profondeur que html.escape sur les autres kinds.
sanitized = sanitize_svg_markup(svg_markup) sanitized = sanitize_svg_markup(svg_markup)
return f'<div class="docImage" data-element-id="{el["id"]}" data-kind="image">{sanitized}</div>' media = (
f'<div class="docImage" data-element-id="{el["id"]}" data-kind="image"{media_style_attr}>{sanitized}</div>'
)
else:
src = html_lib.escape(str(a.get("src", ""))) src = html_lib.escape(str(a.get("src", "")))
alt = html_lib.escape(str(a.get("alt", ""))) alt = html_lib.escape(str(a.get("alt", "")))
if not src: if not src:
return ( media = (
f'<div class="docImage docImagePlaceholder" data-element-id="{el["id"]}" data-kind="image">' f'<div class="docImage docImagePlaceholder" data-element-id="{el["id"]}" '
f"Image — aucun fichier choisi</div>" f'data-kind="image"{media_style_attr}>Image — aucun fichier choisi</div>'
) )
return f'<img class="docImage" data-element-id="{el["id"]}" data-kind="image" src="{src}" alt="{alt}">' else:
media = (
f'<img class="docImage" data-element-id="{el["id"]}" data-kind="image" '
f'src="{src}" alt="{alt}"{media_style_attr}{loading_attr}>'
)
# Comportement au clic (mutuellement exclusif, voir panneau
# Propriétés) — "lien" ouvre une URL externe dans un nouvel onglet
# (jamais dans l'éditeur lui-même), "plein écran" ouvre un aperçu
# agrandi géré côté client (voir static/document/js/
# document-editor.js::forgeDocOpenImageLightbox), tous deux
# UNIQUEMENT actifs en Mode Aperçu (même principe que les mini-jeux
# et la pièce jointe d'un bouton). Reçoit le style de bloc UNIQUEMENT
# s'il n'y a pas de légende par-dessus (sinon c'est elle, plus
# englobante encore, qui le reçoit juste plus bas).
if click_behavior == "link" and link_url:
href = html_lib.escape(link_url)
wrapper_style_attr = f' style="{box_style}"' if (box_style and not caption) else ""
media = (
f'<a class="docImageLink" href="{href}" target="_blank" '
f'rel="noopener noreferrer"{wrapper_style_attr}>{media}</a>'
)
elif click_behavior == "lightbox":
wrapper_style_attr = f' style="{box_style}"' if (box_style and not caption) else ""
media = f'<div class="docImageLightboxTrigger"{wrapper_style_attr}>{media}</div>'
if caption:
figure_style_attr = f' style="{box_style}"' if box_style else ""
media = (
f'<figure class="docImageFigure"{figure_style_attr}>{media}'
f'<figcaption class="docImageCaption">{html_lib.escape(caption)}</figcaption></figure>'
)
return media
def _render_list(el: dict[str, Any], _children_by_parent: dict[int | None, list[dict[str, Any]]]) -> str: def _render_list(el: dict[str, Any], _children_by_parent: dict[int | None, list[dict[str, Any]]]) -> str:
+29 -10
View File
@@ -52,7 +52,21 @@ regroupement à chaque appel.
`<div>` portant directement ce fragment SVG nettoyé par `<div>` portant directement ce fragment SVG nettoyé par
`sanitize_svg_markup` (voir `sanitize_svg_markup.py` ci-dessous) : un `sanitize_svg_markup` (voir `sanitize_svg_markup.py` ci-dessous) : un
contenu vectoriel dessiné/collé par le créateur plutôt qu'un fichier contenu vectoriel dessiné/collé par le créateur plutôt qu'un fichier
hébergé, sans aucun style qui lui soit propre. hébergé. Style inline : `object_fit` (`"cover"`/`"contain"`/`"fill"`,
toute autre valeur ignorée), `aspect_ratio` (valeur CSS libre, ex.
`"16 / 9"`), `filter_preset` (`"grayscale"`/`"sepia"`/`"blur"`, mappé
vers une vraie valeur `filter` CSS fixe — jamais une valeur de filtre
libre) + les attributs de boîte partagés (`render_box_style`, voir
`box_style.py`). `lazy_load` (`True`) ajoute `loading="lazy"` sur
l'`<img>` uniquement (comportement, pas du style). `click_behavior`
(`""`/`"link"`/`"lightbox"`) enveloppe le tout dans un `<a target="_blank"
rel="noopener noreferrer">` (si `link_url` est aussi renseigné) ou un
`<div class="docImageLightboxTrigger">` — les deux ne deviennent
réellement cliquables qu'en Mode Aperçu (voir static/document/js/
document-editor.js::forgeDocBindCanvasInteractions/
forgeDocOpenImageLightbox), même principe que les mini-jeux et la
pièce jointe d'un bouton. `caption` (non vide) enveloppe le tout dans
un `<figure><figcaption>` échappée.
- **Bouton** : `<button>` avec son `label`, un `data-target` optionnel - **Bouton** : `<button>` avec son `label`, un `data-target` optionnel
(navigation) et un `data-attachment-filename` optionnel — marqueur (navigation) et un `data-attachment-filename` optionnel — marqueur
mécanique posé quand un fichier a été joint (voir mécanique posé quand un fichier a été joint (voir
@@ -213,17 +227,22 @@ Un dict à 4 clés (`BORDER_SIDES`), chacune `{"style": "none", "width":
### `BOX_DEFAULTS: dict[str, Any]` ### `BOX_DEFAULTS: dict[str, Any]`
`{"padding": "", "margin": "", "background_color": "", "border_radius": `{"padding": "", "margin": "", "background_color": "", "border_radius":
"", "align_self": "stretch", "height": "", "min_height": "", "", "align_self": "stretch", "width": "", "max_width": "", "height": "",
"max_height": "", "min_width": "", "box_shadow": "", "opacity": ""}` — "min_height": "", "max_height": "", "min_width": "", "box_shadow": "",
`border` n'y figure PAS (voir `default_border()`, à ajouter séparément "opacity": ""}` — `border` n'y figure PAS (voir `default_border()`, à
par chaque appelant pour éviter le partage par référence). ajouter séparément par chaque appelant pour éviter le partage par
référence).
### `render_box_style(a: dict[str, Any]) -> str` ### `render_box_style(a: dict[str, Any]) -> str`
Construit les déclarations CSS inline pour `padding`/`margin`/ Construit les déclarations CSS inline pour chaque attribut listé dans
`background_color`/`border_radius`/`height`/`min_height`/`max_height`/ `BOX_DEFAULTS` (padding/margin/background_color/border_radius/width/
`min_width`/`box_shadow`/`opacity`/`border`/`align_self` de `a` — un max_width/height/min_height/max_height/min_width/box_shadow/opacity) +
attribut absent ou à sa valeur par défaut ne produit AUCUNE déclaration `border`/`align_self` de `a`, via une table de correspondance
(comportement historique inchangé). `border` est un dict à 4 clés (clé d'attribut, propriété CSS) plutôt qu'un bloc `if` par attribut
(complexité cognitive — voir `_render_simple_properties`/`_render_border`,
privées) — un attribut absent ou à sa valeur par défaut ne produit
AUCUNE déclaration (comportement historique inchangé). `border` est un
dict à 4 clés
(`BORDER_SIDES`), chacune `{"style", "width", "color"}` — un côté à (`BORDER_SIDES`), chacune `{"style", "width", "color"}` — un côté à
`style="none"` (ou absent) ne produit rien pour ce côté, jamais un `style="none"` (ou absent) ne produit rien pour ce côté, jamais un
`border-top:none` explicite. `align-self` n'est ajouté que si différent `border-top:none` explicite. `align-self` n'est ajouté que si différent
+3
View File
@@ -13,13 +13,16 @@ from . import ( # noqa: F401 - enregistre les routes definies dans chaque modul
document_element_move_to_page, document_element_move_to_page,
document_element_update, document_element_update,
document_element_upload_attachment, document_element_upload_attachment,
document_element_upload_image,
document_new, document_new,
document_page_add, document_page_add,
document_page_delete, document_page_delete,
document_page_delete_all,
document_page_move, document_page_move,
document_page_rename, document_page_rename,
document_page_vertical_align, document_page_vertical_align,
document_render, document_render,
document_theme_apply, document_theme_apply,
document_theme_preview, document_theme_preview,
document_uploaded_file,
) )
+11 -2
View File
@@ -37,12 +37,21 @@ def document_edit(slug: str) -> str:
constaté le 21/09/2026).""" constaté le 21/09/2026)."""
support = db.support_meta(slug) support = db.support_meta(slug)
pages = document_engine.list_document_pages(slug) pages = document_engine.list_document_pages(slug)
active_page = pages[0] # active_page peut être None : un support peut avoir 0 page (retour
# utilisateur du 26/09/2026, voir document_engine/pages/pages.md) — le
# canevas et le panneau Pages doivent alors afficher un état "aucune
# page" plutôt que de planter, voir document_edit.html et
# document-editor.js (forgeDocSwitchPage/forgeDocRefreshCanvas).
active_page = pages[0] if pages else None
active_elements: list[dict[str, Any]] = [ active_elements: list[dict[str, Any]] = (
[
{**el, "attributes": document_engine.sanitize_element_attributes(el["kind"], el["attributes"])} {**el, "attributes": document_engine.sanitize_element_attributes(el["kind"], el["attributes"])}
for el in document_engine.list_document_elements(slug, active_page["id"]) for el in document_engine.list_document_elements(slug, active_page["id"])
] ]
if active_page is not None
else []
)
active_theme = document_engine.get_document_theme_entry(support["theme"]) if support["theme"] else None active_theme = document_engine.get_document_theme_entry(support["theme"]) if support["theme"] else None
@@ -0,0 +1,61 @@
import os
import uuid
from typing import Any
from flask import jsonify, request, url_for
from werkzeug.wrappers import Response
import db
import document_engine
from core.flask_app import app
_ALLOWED_IMAGE_EXTENSIONS = (".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg")
@app.route("/document/<slug>/elements/<int:element_id>/upload-image", methods=["POST"])
def document_element_upload_image(slug: str, element_id: int) -> Response | tuple[Response, int]:
"""Téléverse un fichier image pour un élément "image" (voir
_render_image, document_engine/rendering/render_document_element.py)
— mirroir de document_element_upload_attachment.py, mais stocké sous
`uploads/` (jamais `attachments/`, réservé au bouton) et réservé au
kind "image". `svg_markup` est vidé au passage : un fichier
téléversé implique `src`, jamais les deux modes en même temps (voir
element_default_attributes, `svg_markup` prioritaire sur `src` au
rendu — le vider ici évite qu'un ancien code SVG masque
silencieusement la photo qui vient d'être choisie)."""
element = document_engine.get_document_element(slug, element_id)
if element is None:
return jsonify({"error": "élément introuvable"}), 404
if element["kind"] != "image":
return jsonify({"error": "seul un élément image peut recevoir un fichier téléversé"}), 400
f = request.files.get("file")
if not f or not f.filename:
return jsonify({"error": "Aucun fichier reçu"}), 400
ext = os.path.splitext(f.filename)[1].lower()
if ext not in _ALLOWED_IMAGE_EXTENSIONS:
return jsonify({"error": "Format d'image non pris en charge (PNG, JPG, GIF, WEBP, SVG uniquement)"}), 400
stored_name = uuid.uuid4().hex + ext
uploads_dir = os.path.join(db.support_dir(slug), "uploads")
os.makedirs(uploads_dir, exist_ok=True)
f.save(os.path.join(uploads_dir, stored_name))
attributes = {
**element["attributes"],
"src": url_for("document_uploaded_file", slug=slug, filename=stored_name),
"svg_markup": "",
}
document_engine.update_document_element_attributes(slug, element_id, attributes)
element = db.assert_not_none(
document_engine.get_document_element(slug, element_id),
"element_id verifie present juste au-dessus, aucune suppression concurrente possible entre-temps ici",
)
elements_by_parent: dict[int | None, list[dict[str, Any]]] = {}
for el in document_engine.list_document_elements(slug, element["page_id"]):
elements_by_parent.setdefault(el["parent_id"], []).append(el)
return jsonify(
{
"ok": True,
"attributes": element["attributes"],
"rendered_html": document_engine.render_document_element(element, elements_by_parent),
}
)
+5 -7
View File
@@ -7,14 +7,12 @@ from core.flask_app import app
@app.route("/document/<slug>/pages/<int:page_id>/delete", methods=["POST"]) @app.route("/document/<slug>/pages/<int:page_id>/delete", methods=["POST"])
def document_page_delete(slug: str, page_id: int) -> Response | tuple[Response, int]: def document_page_delete(slug: str, page_id: int) -> Response | tuple[Response, int]:
"""Supprime une page — refuse de supprimer la DERNIÈRE page restante """Supprime une page, y compris la dernière restante — un support à 0
(un support a toujours au moins une page, voir document_engine/pages/ page est un état valide (retour utilisateur du 26/09/2026 : "l'éditeur
pages.md) : le garde-fou vit ici, à la route, pas dans ne dois plus etre obliger d'avoir une page active", voir
delete_document_page (même découpage que routes/game/screens/ document_engine/pages/pages.md). Voir aussi document_page_delete_all.py
screen_delete.py côté jeu, qui bloque aussi au niveau route).""" pour tout supprimer d'un coup."""
if document_engine.get_document_page(slug, page_id) is None: if document_engine.get_document_page(slug, page_id) is None:
return jsonify({"error": "page introuvable"}), 404 return jsonify({"error": "page introuvable"}), 404
if len(document_engine.list_document_pages(slug)) <= 1:
return jsonify({"error": "impossible de supprimer la dernière page"}), 400
document_engine.delete_document_page(slug, page_id) document_engine.delete_document_page(slug, page_id)
return jsonify({"ok": True}) return jsonify({"ok": True})
@@ -0,0 +1,17 @@
from flask import jsonify
from werkzeug.wrappers import Response
import document_engine
from core.flask_app import app
@app.route("/document/<slug>/pages/delete-all", methods=["POST"])
def document_page_delete_all(slug: str) -> Response:
"""Supprime toutes les pages du support d'un coup (retour utilisateur :
"une option dans page pour supprimer toute les page d'un coup") — le
support se retrouve à 0 page, exactement comme un support neuf. Action
destructive et irréversible, jamais appelée sans confirmation
explicite côté client (voir static/document/js/document-editor.js,
forgeDocDeleteAllPages)."""
document_engine.delete_all_document_pages(slug)
return jsonify({"ok": True})
+19
View File
@@ -0,0 +1,19 @@
import os
from flask import send_from_directory
from werkzeug.wrappers import Response
import db
from core.flask_app import app
@app.route("/document/<slug>/uploads/<path:filename>")
def document_uploaded_file(slug: str, filename: str) -> Response:
"""Sert un fichier téléversé pour ce support (voir
document_element_upload_image.py) — mirroir de
routes/uploads/uploaded_file.py côté jeu, mais sous le dossier du
SUPPORT (db.support_dir). Affiché inline (jamais en téléchargement,
contrairement à document_element_download_attachment.py) : c'est une
image destinée à s'afficher dans la page, pas un fichier à
récupérer."""
return send_from_directory(os.path.join(db.support_dir(slug), "uploads"), filename)
+81 -1
View File
@@ -604,8 +604,24 @@ body.objectEditBody > main.content {
font-weight: 600; font-weight: 600;
} }
/* max-height : filet de sécurité pour une hauteur explicite (ou
l'absence de hauteur, image affichée à sa taille native) qui
dépasserait déraisonnablement une page (bug réel constaté le
26/09/2026 : "quand j'ajoute une image ça crée des pages à
l'infini") — le cadre FIXE par défaut (hauteur + object-fit:cover)
vit désormais dans les attributs par défaut d'un nouvel élément image
(voir element_kind_labels.element_default_attributes, retour
utilisateur : "ce cadre ne devrait pas changer de taille en fonction
de la taille de l'image mais être fixe"), pas ici en CSS : "Taille
réelle" (object_fit vide) doit rester un choix explicite possible
dans le panneau Propriétés, qu'un défaut CSS aveugle sur TOUTE image
casserait silencieusement (aucune façon de distinguer en CSS "jamais
réglé" de "réglé sur Taille réelle", les deux valant la chaîne vide).
Un réglage explicite de hauteur maximale prime toujours sur ce filet
(spécificité inline > feuille externe). */
img.docImage { img.docImage {
max-width: 100%; max-width: 100%;
max-height: calc(var(--doc-page-width, 960px) * 210 / 297 - 96px);
border-radius: 10px; border-radius: 10px;
display: block; display: block;
} }
@@ -1427,7 +1443,8 @@ img.docImage {
23/09/2026 : "le bouton pour ajouter une page dois être en haut 23/09/2026 : "le bouton pour ajouter une page dois être en haut
toujours visible") — flex-shrink:0 pour ne jamais être compressé par toujours visible") — flex-shrink:0 pour ne jamais être compressé par
.docPageManagerList, seule cette dernière défile. */ .docPageManagerList, seule cette dernière défile. */
.docPageManagerAdd { .docPageManagerAdd,
.docPageManagerDeleteAll {
flex-shrink: 0; flex-shrink: 0;
} }
@@ -1799,6 +1816,18 @@ img.docImage {
padding: 30px 10px; padding: 30px 10px;
} }
/* État "aucune page" du canevas (retour utilisateur du 26/09/2026 :
l'éditeur peut s'ouvrir sans page active) — occupe toute la hauteur
disponible de .docPageContent (flex column, voir plus haut) pour
centrer le message quel que soit vertical_align. */
.docEmptyPageState {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
font-size: 15px;
}
/* ===== BOTTOM NAV (mobile) ===== */ /* ===== BOTTOM NAV (mobile) ===== */
.docBottomNav { .docBottomNav {
display: none; display: none;
@@ -2563,3 +2592,54 @@ img.docImage {
background: var(--doc-bg-2); background: var(--doc-bg-2);
cursor: pointer; cursor: pointer;
} }
/* ---- Image : légende, lien, plein écran (voir document_engine/
rendering/render_document_element.py::_render_image et
static/document/js/document-editor.js::forgeDocOpenImageLightbox). ---- */
.docImageFigure {
margin: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.docImageCaption {
font-size: 12.5px;
color: var(--doc-muted);
text-align: center;
}
.docImageLink {
display: block;
}
.docImageLightboxTrigger {
display: block;
}
.docEditor3.docEditor3--preview .docImageLightboxTrigger {
cursor: zoom-in;
}
.docImageLightboxOverlay {
display: none;
position: fixed;
inset: 0;
z-index: 300;
background: rgb(11 14 20 / 90%);
align-items: center;
justify-content: center;
padding: 40px;
cursor: zoom-out;
}
.docImageLightboxOverlay.is-open {
display: flex;
}
.docImageLightboxImg {
max-width: 100%;
max-height: 100%;
border-radius: 8px;
box-shadow: 0 20px 60px -20px rgb(0 0 0 / 60%);
}
+297 -24
View File
@@ -47,6 +47,14 @@ function forgeDocApiAdd(kind, parentId) {
// définition à "la page actuellement affichée", inutile de le faire // définition à "la page actuellement affichée", inutile de le faire
// remonter dans chacun des ~10 sites d'appel (glisser-déposer, moteur // remonter dans chacun des ~10 sites d'appel (glisser-déposer, moteur
// de layout, Annuler/Rétablir...). // de layout, Annuler/Rétablir...).
// Point d'entrée UNIQUE de tout ajout : un support peut avoir 0 page
// (retour utilisateur du 26/09/2026) — bloquer ICI, une fois, couvre
// tous les appelants (bibliothèque, glisser-déposer, rangée créée à la
// volée) sans dupliquer ce contrôle dans chacun.
if (window.forgeDocState.activePageId === null) {
alert('Ajoutez d\'abord une page avant d\'ajouter du contenu.');
return Promise.reject(new Error('aucune page active'));
}
const body = new URLSearchParams(); const body = new URLSearchParams();
body.set('kind', kind); body.set('kind', kind);
body.set('page_id', String(window.forgeDocState.activePageId)); body.set('page_id', String(window.forgeDocState.activePageId));
@@ -108,6 +116,16 @@ function forgeDocApiUploadAttachment(id, file) {
.then((r) => r.json()); .then((r) => r.json());
} }
function forgeDocApiUploadImage(id, file) {
// Mirroir de forgeDocApiUploadAttachment ci-dessus, mais route dédiée
// au kind "image" (voir routes/document/document_element_upload_image.py) :
// stocke sous uploads/ (jamais attachments/) et vide svg_markup côté serveur.
const body = new FormData();
body.set('file', file);
return fetch(`/document/${window.forgeDocState.slug}/elements/${id}/upload-image`, { method: 'POST', body })
.then((r) => r.json());
}
function forgeDocApiRender() { function forgeDocApiRender() {
const url = `/document/${window.forgeDocState.slug}/render?page_id=${window.forgeDocState.activePageId}`; const url = `/document/${window.forgeDocState.slug}/render?page_id=${window.forgeDocState.activePageId}`;
return fetch(url).then((r) => r.json()); return fetch(url).then((r) => r.json());
@@ -156,18 +174,87 @@ function forgeDocCheckPageOverflow() {
}); });
if (splitIndex === -1) return Promise.resolve(); if (splitIndex === -1) return Promise.resolve();
const overflowingEl = topLevel[splitIndex];
const FORGE_DOC_MIN_IMAGE_HEIGHT = 60;
if (overflowingEl.kind === 'image') {
// Retour utilisateur du 26/09/2026 : "si l'image uploadée est trop
// grande je préfère qu'elle soit redimensionnée plutôt que bouger sur
// une autre page" — une image qui déborde est rétrécie SUR PLACE à
// l'espace réellement disponible sous elle, plutôt que paginée.
// Ajustement purement VISUEL (jamais persisté en attribut), recalculé
// à chaque rafraîchissement du canevas (voir forgeDocRefreshCanvas).
//
// MAIS uniquement s'il reste assez de place pour un résultat encore
// utilisable (retour utilisateur suivant : "le contenu continue de se
// mettre même quand il n'y a plus de place [...] aucune page ne se
// crée automatiquement" — une page déjà pleine d'images empilait des
// vignettes rétrécies à l'infini plutôt que de basculer sur une
// nouvelle page) : sous FORGE_DOC_MIN_IMAGE_HEIGHT, il ne reste plus
// de place RÉELLE sur cette page, pas seulement pour cette image —
// on tombe alors dans la pagination normale ci-dessous, comme
// n'importe quel autre kind.
const dom = document.querySelector(`#docPage [data-element-id="${overflowingEl.id}"]`);
if (dom) {
const figure = dom.closest('.docImageFigure');
const caption = figure ? figure.querySelector('.docImageCaption') : null;
const captionSpace = caption ? caption.getBoundingClientRect().height + 8 : 0;
const available = Math.floor(pageBottom - dom.getBoundingClientRect().top - captionSpace - 2);
if (available >= FORGE_DOC_MIN_IMAGE_HEIGHT) {
dom.style.maxHeight = `${available}px`;
return Promise.resolve();
}
}
}
const overflowing = topLevel.slice(splitIndex); const overflowing = topLevel.slice(splitIndex);
// Garde-fou contre une boucle infinie (bug réel constaté le 26/09/2026 :
// "quand j'ajoute une image ça crée des pages à l'infini") — si TOUT le
// top-niveau de la page déborde déjà, le déplacer sur une page neuve ne
// fait que reproduire EXACTEMENT le même débordement là-bas, à l'infini.
// Rien à répartir dans ce cas : on laisse le contenu tel quel (clipsé
// par .docPage { overflow: hidden }, voir sa règle CSS) — au créateur de
// redimensionner l'élément lui-même.
if (overflowing.length === topLevel.length) return Promise.resolve();
// La page neuve se glisse JUSTE APRÈS la page qui déborde (retour
// utilisateur du 26/09/2026 : "la nouvelle page dois etre sous la page
// qui deborde meme si ya d'autre page") — jamais ajoutée en toute fin
// de la bande d'onglets s'il existe déjà d'autres pages après celle-ci
// (ex. déborder sur la page 2 d'un support qui en compte 5 insère la
// nouvelle page en position 3, décale les anciennes 3/4/5 en 4/5/6).
const insertIndex = forgeDocOrderedPages().findIndex((p) => p.id === window.forgeDocState.activePageId) + 1;
return forgeDocApiPageAdd().then((result) => { return forgeDocApiPageAdd().then((result) => {
window.forgeDocState.pages.push({ window.forgeDocState.pages.push({
id: result.id, title: result.title, order_index: window.forgeDocState.pages.length, id: result.id, title: result.title, order_index: window.forgeDocState.pages.length,
}); });
return overflowing return forgeDocApiPageMove(result.id, insertIndex)
.reduce((chain, el) => chain.then(() => forgeDocApiMoveToPage(el.id, result.id)), Promise.resolve()) .then(() => forgeDocReorderLocalPage(result.id, insertIndex))
.then(() => overflowing.reduce(
(chain, el) => chain.then(() => forgeDocApiMoveToPage(el.id, result.id)),
Promise.resolve(),
))
.then(() => forgeDocSwitchPage(result.id)); .then(() => forgeDocSwitchPage(result.id));
}); });
} }
function forgeDocRenderEmptyPageState() {
// Support à 0 page (retour utilisateur du 26/09/2026) — jamais appeler
// /render sans page_id (404 côté serveur, voir document_render.py) :
// le canevas affiche directement une invite à créer une page.
const contentEl = document.getElementById('docPageContent');
contentEl.innerHTML = '<div class="docEmptyState docEmptyPageState">Aucune page — cliquez sur « + Ajouter une page » pour commencer.</div>';
contentEl.dataset.verticalAlign = 'top';
window.forgeDocState.elements = [];
forgeDocRebuildIndex();
window.forgeDocState.selectedId = null;
forgeDocRefreshRightPanelDefault();
}
function forgeDocRefreshCanvas() { function forgeDocRefreshCanvas() {
if (window.forgeDocState.activePageId === null) {
forgeDocRenderEmptyPageState();
return Promise.resolve();
}
return forgeDocApiRender().then((data) => { return forgeDocApiRender().then((data) => {
const contentEl = document.getElementById('docPageContent'); const contentEl = document.getElementById('docPageContent');
contentEl.innerHTML = data.html; contentEl.innerHTML = data.html;
@@ -293,6 +380,17 @@ function forgeDocUpdateAttributes(id, newAttributes) {
if (window.forgeDocState.selectedId === id) { if (window.forgeDocState.selectedId === id) {
forgeDocRenderProps(el); forgeDocRenderProps(el);
} }
// Bug réel constaté le 26/09/2026 : uploader une image trop grande
// (ou tout autre changement d'attribut qui agrandit un élément) ne
// déclenchait JAMAIS le rétrécissement/la pagination — seules les
// mutations passant par forgeDocRefreshCanvas (ajout/déplacement/
// suppression/Annuler-Rétablir) le faisaient. Cette fonction est le
// point d'entrée UNIQUE de toute mise à jour d'attributs (voir
// commentaire ci-dessus) : vérifier ICI, une fois, couvre aussi
// l'upload d'image (voir forgeDocApiUploadImage plus bas, qui route
// son propre patch DOM à travers ce même point) sans dupliquer
// l'appel dans chaque site qui modifie un attribut.
return forgeDocCheckPageOverflow();
}); });
} }
@@ -523,6 +621,11 @@ function forgeDocApiPageDelete(pageId) {
.then((r) => r.json()); .then((r) => r.json());
} }
function forgeDocApiPageDeleteAll() {
return fetch(`/document/${window.forgeDocState.slug}/pages/delete-all`, { method: 'POST' })
.then((r) => r.json());
}
function forgeDocApiPageMove(pageId, newIndex) { function forgeDocApiPageMove(pageId, newIndex) {
return fetch(`/document/${window.forgeDocState.slug}/pages/${pageId}/move`, { return fetch(`/document/${window.forgeDocState.slug}/pages/${pageId}/move`, {
method: 'POST', method: 'POST',
@@ -543,6 +646,22 @@ function forgeDocOrderedPages() {
return [...window.forgeDocState.pages].sort((a, b) => a.order_index - b.order_index); return [...window.forgeDocState.pages].sort((a, b) => a.order_index - b.order_index);
} }
function forgeDocReorderLocalPage(movedId, newIndex) {
// Reflète côté client un déplacement déjà appliqué côté serveur (voir
// move_document_page.py : renumérotation intégrale de order_index sur
// TOUTES les pages) — mêmes calculs, jamais dupliqués autrement que
// dans cette seule fonction (glisser-déposer du panneau Pages ET
// pagination automatique, voir forgeDocCheckPageOverflow).
const moved = window.forgeDocState.pages.find((p) => p.id === movedId);
const rest = window.forgeDocState.pages
.filter((p) => p.id !== movedId)
.sort((a, b) => a.order_index - b.order_index);
rest.splice(newIndex, 0, moved);
rest.forEach((p, i) => { p.order_index = i; });
window.forgeDocState.pages = rest;
forgeDocRenderPageManagerList();
}
function forgeDocUpdatePageNavPill() { function forgeDocUpdatePageNavPill() {
const pill = document.getElementById('docPageNavPill'); const pill = document.getElementById('docPageNavPill');
if (!pill) return; if (!pill) return;
@@ -599,10 +718,9 @@ function forgeDocSetPageVerticalAlign(pageId, verticalAlign) {
} }
function forgeDocDeletePage(pageId) { function forgeDocDeletePage(pageId) {
// Filet de sécurité client (en plus du refus serveur, voir // Supprimer la dernière page restante est désormais autorisé (retour
// routes/document/document_page_delete.py) : jamais même tenter de // utilisateur du 26/09/2026 : un support à 0 page est un état valide,
// supprimer la dernière page restante. // voir document_engine/pages/pages.md) — plus de garde-fou ici.
if (window.forgeDocState.pages.length <= 1) return Promise.resolve();
return forgeDocApiPageDelete(pageId).then((result) => { return forgeDocApiPageDelete(pageId).then((result) => {
if (result.error) { if (result.error) {
alert(result.error); alert(result.error);
@@ -610,13 +728,30 @@ function forgeDocDeletePage(pageId) {
} }
window.forgeDocState.pages = window.forgeDocState.pages.filter((p) => p.id !== pageId); window.forgeDocState.pages = window.forgeDocState.pages.filter((p) => p.id !== pageId);
if (window.forgeDocState.activePageId === pageId) { if (window.forgeDocState.activePageId === pageId) {
return forgeDocSwitchPage(forgeDocOrderedPages()[0].id); const remaining = forgeDocOrderedPages();
return forgeDocSwitchPage(remaining.length ? remaining[0].id : null);
} }
forgeDocRenderPageManagerList(); forgeDocRenderPageManagerList();
return Promise.resolve(); return Promise.resolve();
}); });
} }
function forgeDocDeleteAllPages() {
// Action destructive et irréversible (retour utilisateur : "une option
// dans page pour supprimer toute les page d'un coup") — confirmation
// native obligatoire avant tout appel serveur.
if (!window.confirm('Supprimer TOUTES les pages et tout leur contenu ? Cette action est irréversible.')) {
return Promise.resolve();
}
return forgeDocApiPageDeleteAll().then(() => {
window.forgeDocState.pages = [];
window.forgeDocState.undoStack = [];
window.forgeDocState.redoStack = [];
forgeDocUpdateHistoryButtons();
return forgeDocSwitchPage(null);
});
}
function forgeDocSelectAllText(el) { function forgeDocSelectAllText(el) {
const range = document.createRange(); const range = document.createRange();
range.selectNodeContents(el); range.selectNodeContents(el);
@@ -701,7 +836,7 @@ function forgeDocRenderPageManagerList() {
<span class="docPageRowTitle"><span class="docPageRowTitleText">${forgeDocEscapeHtml(p.title)}</span></span> <span class="docPageRowTitle"><span class="docPageRowTitleText">${forgeDocEscapeHtml(p.title)}</span></span>
<span class="docPageRowActions"> <span class="docPageRowActions">
<button type="button" class="docPageRowRename" data-page-id="${p.id}" aria-label="Renommer cette page" title="Renommer cette page">✎</button> <button type="button" class="docPageRowRename" data-page-id="${p.id}" aria-label="Renommer cette page" title="Renommer cette page">✎</button>
<button type="button" class="docPageRowDelete" data-page-id="${p.id}" aria-label="Supprimer cette page" title="Supprimer cette page"${pages.length <= 1 ? ' disabled' : ''}>✕</button> <button type="button" class="docPageRowDelete" data-page-id="${p.id}" aria-label="Supprimer cette page" title="Supprimer cette page">✕</button>
</span> </span>
</div> </div>
`).join(''); `).join('');
@@ -733,6 +868,12 @@ function forgeDocRenderPageManagerList() {
const addBtn = document.getElementById('docPageManagerAdd'); const addBtn = document.getElementById('docPageManagerAdd');
if (addBtn) addBtn.onclick = () => forgeDocAddPage(); if (addBtn) addBtn.onclick = () => forgeDocAddPage();
const deleteAllBtn = document.getElementById('docPageManagerDeleteAll');
if (deleteAllBtn) {
deleteAllBtn.disabled = pages.length === 0;
deleteAllBtn.onclick = () => forgeDocDeleteAllPages();
}
} }
function forgeDocBindPageManagerDrag() { function forgeDocBindPageManagerDrag() {
@@ -769,16 +910,7 @@ function forgeDocBindPageManagerDrag() {
const movedId = draggedId; const movedId = draggedId;
draggedId = null; draggedId = null;
if (newIndex < 0) return; if (newIndex < 0) return;
forgeDocApiPageMove(movedId, newIndex).then(() => { forgeDocApiPageMove(movedId, newIndex).then(() => forgeDocReorderLocalPage(movedId, newIndex));
const moved = window.forgeDocState.pages.find((p) => p.id === movedId);
const rest = window.forgeDocState.pages
.filter((p) => p.id !== movedId)
.sort((a, b) => a.order_index - b.order_index);
rest.splice(newIndex, 0, moved);
rest.forEach((p, i) => { p.order_index = i; });
window.forgeDocState.pages = rest;
forgeDocRenderPageManagerList();
});
}); });
} }
@@ -826,6 +958,35 @@ function forgeDocBindPageNavPill() {
* Sélection & panneau Propriétés. * Sélection & panneau Propriétés.
* ------------------------------------------------------------------- */ * ------------------------------------------------------------------- */
/* ---------------------------------------------------------------------
* Aperçu plein écran d'une image (comportement "click_behavior":
* "lightbox", voir document_engine/rendering/render_document_element.py
* ::_render_image) — un seul overlay réutilisé, créé à la demande.
* ------------------------------------------------------------------- */
function forgeDocOpenImageLightbox(src, alt) {
let overlay = document.getElementById('docImageLightboxOverlay');
if (!overlay) {
overlay = document.createElement('div');
overlay.id = 'docImageLightboxOverlay';
overlay.className = 'docImageLightboxOverlay';
overlay.innerHTML = '<img class="docImageLightboxImg" id="docImageLightboxImg" alt="">';
overlay.addEventListener('click', forgeDocCloseImageLightbox);
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') forgeDocCloseImageLightbox();
});
document.body.appendChild(overlay);
}
document.getElementById('docImageLightboxImg').src = src;
document.getElementById('docImageLightboxImg').alt = alt || '';
overlay.classList.add('is-open');
}
function forgeDocCloseImageLightbox() {
const overlay = document.getElementById('docImageLightboxOverlay');
if (overlay) overlay.classList.remove('is-open');
}
function forgeDocBindCanvasInteractions() { function forgeDocBindCanvasInteractions() {
document.querySelectorAll('#docPage [data-element-id]').forEach((dom) => { document.querySelectorAll('#docPage [data-element-id]').forEach((dom) => {
const elementId = Number.parseInt(dom.dataset.elementId, 10); const elementId = Number.parseInt(dom.dataset.elementId, 10);
@@ -839,6 +1000,22 @@ function forgeDocBindCanvasInteractions() {
window.location.href = `/document/${window.forgeDocState.slug}/elements/${elementId}/download-attachment`; window.location.href = `/document/${window.forgeDocState.slug}/elements/${elementId}/download-attachment`;
return; return;
} }
// Image "plein écran au clic" — seulement en Aperçu (même
// principe que ci-dessus) ; en édition, on bloque juste la
// navigation native éventuelle (lien/plein écran) pour rester sur
// la sélection de l'élément.
const lightboxWrapper = dom.closest('.docImageLightboxTrigger');
if (lightboxWrapper) {
e.preventDefault();
if (window.forgeDocState.previewMode) {
const img = lightboxWrapper.querySelector('img.docImage');
if (img) forgeDocOpenImageLightbox(img.src, img.alt);
return;
}
}
if (dom.closest('.docImageLink') && !window.forgeDocState.previewMode) {
e.preventDefault();
}
forgeDocSelectElement(elementId); forgeDocSelectElement(elementId);
}); });
dom.setAttribute('draggable', 'true'); dom.setAttribute('draggable', 'true');
@@ -1807,6 +1984,12 @@ function forgeDocRenderBoxFieldsHtml(a, idPrefix) {
</div> </div>
${forgeDocRenderToggleSliderHtml(`${idPrefix}Radius`, 'Arrondi des bords', a.border_radius, { ${forgeDocRenderToggleSliderHtml(`${idPrefix}Radius`, 'Arrondi des bords', a.border_radius, {
min: 0, max: 100, step: 2, unit: 'px', fallback: 8, min: 0, max: 100, step: 2, unit: 'px', fallback: 8,
})}
${forgeDocRenderToggleSliderHtml(`${idPrefix}Width`, 'Largeur fixe', a.width, {
min: 50, max: 1200, step: 10, unit: 'px', fallback: 300,
})}
${forgeDocRenderToggleSliderHtml(`${idPrefix}MaxWidth`, 'Largeur maximale', a.max_width, {
min: 200, max: 1200, step: 20, unit: 'px', fallback: 600,
})} })}
${forgeDocRenderToggleSliderHtml(`${idPrefix}Height`, 'Hauteur fixe', a.height, { ${forgeDocRenderToggleSliderHtml(`${idPrefix}Height`, 'Hauteur fixe', a.height, {
min: 0, max: 800, step: 10, unit: 'px', fallback: 200, min: 0, max: 800, step: 10, unit: 'px', fallback: 200,
@@ -1859,6 +2042,8 @@ function forgeDocBindBoxFields(panel, el, idPrefix, patch) {
forgeDocBindToggleSlider(`${idPrefix}Padding`, { unit: 'px' }, (v) => patch({ padding: v })); forgeDocBindToggleSlider(`${idPrefix}Padding`, { unit: 'px' }, (v) => patch({ padding: v }));
forgeDocBindToggleSlider(`${idPrefix}Margin`, { unit: 'px' }, (v) => patch({ margin: v })); forgeDocBindToggleSlider(`${idPrefix}Margin`, { unit: 'px' }, (v) => patch({ margin: v }));
forgeDocBindToggleSlider(`${idPrefix}Radius`, { unit: 'px' }, (v) => patch({ border_radius: v })); forgeDocBindToggleSlider(`${idPrefix}Radius`, { unit: 'px' }, (v) => patch({ border_radius: v }));
forgeDocBindToggleSlider(`${idPrefix}Width`, { unit: 'px' }, (v) => patch({ width: v }));
forgeDocBindToggleSlider(`${idPrefix}MaxWidth`, { unit: 'px' }, (v) => patch({ max_width: v }));
forgeDocBindToggleSlider(`${idPrefix}Height`, { unit: 'px' }, (v) => patch({ height: v })); forgeDocBindToggleSlider(`${idPrefix}Height`, { unit: 'px' }, (v) => patch({ height: v }));
forgeDocBindToggleSlider(`${idPrefix}MinHeight`, { unit: 'px' }, (v) => patch({ min_height: v })); forgeDocBindToggleSlider(`${idPrefix}MinHeight`, { unit: 'px' }, (v) => patch({ min_height: v }));
forgeDocBindToggleSlider(`${idPrefix}MaxHeight`, { unit: 'px' }, (v) => patch({ max_height: v })); forgeDocBindToggleSlider(`${idPrefix}MaxHeight`, { unit: 'px' }, (v) => patch({ max_height: v }));
@@ -1959,9 +2144,6 @@ function forgeDocRenderTextProps(panel, el) {
${FORGE_DOC_TEXT_SHADOW_PRESETS.map(([v, l]) => `<button type="button" class="docSegBtn ${(a.text_shadow || '') === v ? 'is-active' : ''}" data-shadow="${forgeDocEscapeHtml(v)}">${l}</button>`).join('')} ${FORGE_DOC_TEXT_SHADOW_PRESETS.map(([v, l]) => `<button type="button" class="docSegBtn ${(a.text_shadow || '') === v ? 'is-active' : ''}" data-shadow="${forgeDocEscapeHtml(v)}">${l}</button>`).join('')}
</div> </div>
</div> </div>
${forgeDocRenderToggleSliderHtml('docMaxWidth', 'Largeur maximale', a.max_width, {
min: 200, max: 1200, step: 20, unit: 'px', fallback: 600,
})}
${forgeDocRenderBoxFieldsHtml(a, 'docText')} ${forgeDocRenderBoxFieldsHtml(a, 'docText')}
${forgeDocDeleteButtonHtml()} ${forgeDocDeleteButtonHtml()}
`; `;
@@ -1969,7 +2151,6 @@ function forgeDocRenderTextProps(panel, el) {
function patch(partial) { forgeDocUpdateAttributes(el.id, { ...el.attributes, ...partial }); } function patch(partial) { forgeDocUpdateAttributes(el.id, { ...el.attributes, ...partial }); }
document.getElementById('docContentInput').addEventListener('change', (e) => patch({ content: e.target.value })); document.getElementById('docContentInput').addEventListener('change', (e) => patch({ content: e.target.value }));
forgeDocBindToggleSlider('docMaxWidth', { unit: 'px' }, (v) => patch({ max_width: v }));
panel.querySelectorAll('#docStyleSeg .docSegBtn').forEach((btn) => { panel.querySelectorAll('#docStyleSeg .docSegBtn').forEach((btn) => {
btn.addEventListener('click', () => patch({ style: btn.dataset.style })); btn.addEventListener('click', () => patch({ style: btn.dataset.style }));
}); });
@@ -1994,19 +2175,111 @@ function forgeDocRenderTextProps(panel, el) {
forgeDocBindBoxFields(panel, el, 'docText', patch); forgeDocBindBoxFields(panel, el, 'docText', patch);
} }
const FORGE_DOC_OBJECT_FIT_OPTIONS = [
['', 'Taille réelle'], ['cover', 'Couvrir'], ['contain', 'Contenir'], ['fill', 'Étirer'],
];
const FORGE_DOC_ASPECT_RATIO_OPTIONS = [
['', 'Libre'], ['1 / 1', 'Carré'], ['4 / 3', '4:3'], ['16 / 9', '16:9'],
];
const FORGE_DOC_FILTER_OPTIONS = [
['', 'Aucun'], ['grayscale', 'Noir et blanc'], ['sepia', 'Sépia'], ['blur', 'Flouté'],
];
const FORGE_DOC_IMAGE_CLICK_OPTIONS = [
['', 'Aucun'], ['link', 'Ouvrir un lien'], ['lightbox', 'Plein écran'],
];
function forgeDocRenderImageProps(panel, el) { function forgeDocRenderImageProps(panel, el) {
const a = el.attributes; const a = el.attributes;
const clickBehavior = a.click_behavior || '';
panel.innerHTML = ` panel.innerHTML = `
${forgeDocPropsHeader('image')} ${forgeDocPropsHeader('image')}
<div class="docField"><label for="docImageSrc">URL de l'image</label><input type="text" id="docImageSrc" value="${a.src || ''}" placeholder="https://..."></div> <div class="docField">
<div class="docField"><label for="docImageAlt">Texte alternatif</label><input type="text" id="docImageAlt" value="${a.alt || ''}"></div> <span class="docFieldLabel">Fichier image</span>
<div id="docImageUploadStatus">${a.src ? 'Image actuelle en place' : 'Aucun fichier choisi'}</div>
<input type="file" id="docImageUploadInput" accept="image/png,image/jpeg,image/gif,image/webp,image/svg+xml">
</div>
<div class="docField"><label for="docImageSrc">Ou URL d'une image externe</label><input type="text" id="docImageSrc" value="${forgeDocEscapeHtml(a.src || '')}" placeholder="https://..."></div>
<div class="docField"><label for="docImageAlt">Texte alternatif</label><input type="text" id="docImageAlt" value="${forgeDocEscapeHtml(a.alt || '')}"></div>
<div class="docField"><label for="docImageSvg">Code SVG (prioritaire sur l'URL si renseigné)</label><textarea id="docImageSvg" rows="4">${forgeDocEscapeHtml(a.svg_markup || '')}</textarea></div> <div class="docField"><label for="docImageSvg">Code SVG (prioritaire sur l'URL si renseigné)</label><textarea id="docImageSvg" rows="4">${forgeDocEscapeHtml(a.svg_markup || '')}</textarea></div>
<div class="docField"><label for="docImageCaption">Légende (optionnel)</label><input type="text" id="docImageCaption" value="${forgeDocEscapeHtml(a.caption || '')}"></div>
<div class="docField">
<span class="docFieldLabel">Ajustement dans son cadre</span>
<div class="docSegmented" id="docImageFitSeg">
${FORGE_DOC_OBJECT_FIT_OPTIONS.map(([v, l]) => `<button type="button" class="docSegBtn ${(a.object_fit || '') === v ? 'is-active' : ''}" data-fit="${v}">${l}</button>`).join('')}
</div>
</div>
<div class="docField">
<span class="docFieldLabel">Format (ratio)</span>
<div class="docSegmented" id="docImageRatioSeg">
${FORGE_DOC_ASPECT_RATIO_OPTIONS.map(([v, l]) => `<button type="button" class="docSegBtn ${(a.aspect_ratio || '') === v ? 'is-active' : ''}" data-ratio="${forgeDocEscapeHtml(v)}">${l}</button>`).join('')}
</div>
</div>
<div class="docField">
<span class="docFieldLabel">Filtre</span>
<div class="docSegmented" id="docImageFilterSeg">
${FORGE_DOC_FILTER_OPTIONS.map(([v, l]) => `<button type="button" class="docSegBtn ${(a.filter_preset || '') === v ? 'is-active' : ''}" data-filter="${v}">${l}</button>`).join('')}
</div>
</div>
<div class="docField">
<label class="docCheckboxLabel"><input type="checkbox" id="docImageLazyLoad" ${a.lazy_load ? 'checked' : ''}> Chargement différé (images longues à charger)</label>
</div>
<div class="docField">
<span class="docFieldLabel">Au clic (en Aperçu)</span>
<div class="docSegmented" id="docImageClickSeg">
${FORGE_DOC_IMAGE_CLICK_OPTIONS.map(([v, l]) => `<button type="button" class="docSegBtn ${clickBehavior === v ? 'is-active' : ''}" data-click="${v}">${l}</button>`).join('')}
</div>
</div>
<div class="docField" id="docImageLinkUrlField" ${clickBehavior === 'link' ? '' : 'hidden'}>
<label for="docImageLinkUrl">URL du lien</label>
<input type="text" id="docImageLinkUrl" value="${forgeDocEscapeHtml(a.link_url || '')}" placeholder="https://...">
</div>
${forgeDocRenderBoxFieldsHtml(a, 'docImage')}
${forgeDocDeleteButtonHtml()} ${forgeDocDeleteButtonHtml()}
`; `;
function patch(partial) { forgeDocUpdateAttributes(el.id, { ...el.attributes, ...partial }); } function patch(partial) { forgeDocUpdateAttributes(el.id, { ...el.attributes, ...partial }); }
document.getElementById('docImageUploadInput').addEventListener('change', (e) => {
const file = e.target.files[0];
if (!file) return;
forgeDocApiUploadImage(el.id, file).then((result) => {
if (result.error) { alert(result.error); return; }
el.attributes = result.attributes;
forgeDocRenderProps(window.forgeDocState.elementsById[el.id]);
const dom = document.querySelector(`[data-element-id="${el.id}"]`);
if (dom && result.rendered_html) {
const wrapper = document.createElement('div');
wrapper.innerHTML = result.rendered_html;
dom.replaceWith(wrapper.firstElementChild);
forgeDocBindCanvasInteractions();
forgeDocApplySelectionClass();
}
// Bug réel constaté le 26/09/2026 : "quand j'ajoute une image ça
// crée des pages à l'infini" / "ce n'est pas redimensionner" —
// cette route d'upload patch le DOM elle-même (fichier multipart,
// jamais via forgeDocApiUpdate/forgeDocUpdateAttributes qui fait
// déjà cette vérification), donc jamais couverte par le point
// d'entrée central. Vérifiée ICI explicitement pour cette raison.
forgeDocCheckPageOverflow();
});
});
document.getElementById('docImageSrc').addEventListener('change', (e) => patch({ src: e.target.value.trim() })); document.getElementById('docImageSrc').addEventListener('change', (e) => patch({ src: e.target.value.trim() }));
document.getElementById('docImageAlt').addEventListener('change', (e) => patch({ alt: e.target.value })); document.getElementById('docImageAlt').addEventListener('change', (e) => patch({ alt: e.target.value }));
document.getElementById('docImageSvg').addEventListener('change', (e) => patch({ svg_markup: e.target.value.trim() })); document.getElementById('docImageSvg').addEventListener('change', (e) => patch({ svg_markup: e.target.value.trim() }));
document.getElementById('docImageCaption').addEventListener('change', (e) => patch({ caption: e.target.value.trim() }));
panel.querySelectorAll('#docImageFitSeg .docSegBtn').forEach((btn) => {
btn.addEventListener('click', () => patch({ object_fit: btn.dataset.fit }));
});
panel.querySelectorAll('#docImageRatioSeg .docSegBtn').forEach((btn) => {
btn.addEventListener('click', () => patch({ aspect_ratio: btn.dataset.ratio }));
});
panel.querySelectorAll('#docImageFilterSeg .docSegBtn').forEach((btn) => {
btn.addEventListener('click', () => patch({ filter_preset: btn.dataset.filter }));
});
document.getElementById('docImageLazyLoad').addEventListener('change', (e) => patch({ lazy_load: e.target.checked }));
panel.querySelectorAll('#docImageClickSeg .docSegBtn').forEach((btn) => {
btn.addEventListener('click', () => patch({ click_behavior: btn.dataset.click }));
});
document.getElementById('docImageLinkUrl').addEventListener('change', (e) => patch({ link_url: e.target.value.trim() }));
forgeDocBindBoxFields(panel, el, 'docImage', patch);
} }
function forgeDocRenderButtonProps(panel, el) { function forgeDocRenderButtonProps(panel, el) {
+8 -5
View File
@@ -152,6 +152,10 @@
toujours visible" — avec beaucoup de pages, il disparaissait en toujours visible" — avec beaucoup de pages, il disparaissait en
bas de la liste défilante). #} bas de la liste défilante). #}
<button type="button" class="docBtnSecondary docPageManagerAdd" id="docPageManagerAdd">+ Ajouter une page</button> <button type="button" class="docBtnSecondary docPageManagerAdd" id="docPageManagerAdd">+ Ajouter une page</button>
<button type="button"
class="docDeleteBtn docPageManagerDeleteAll"
id="docPageManagerDeleteAll"
{{ 'disabled' if not pages else '' }}>🗑 Supprimer toutes les pages</button>
<div class="docPageManagerList" id="docPageManagerList"> <div class="docPageManagerList" id="docPageManagerList">
{% for page in pages %} {% for page in pages %}
<div class="docPageRow {{ 'is-active' if page.id == active_page.id else '' }}" <div class="docPageRow {{ 'is-active' if page.id == active_page.id else '' }}"
@@ -178,8 +182,7 @@
class="docPageRowDelete" class="docPageRowDelete"
data-page-id="{{ page.id }}" data-page-id="{{ page.id }}"
aria-label="Supprimer cette page" aria-label="Supprimer cette page"
title="Supprimer cette page" title="Supprimer cette page">✕</button>
{{ 'disabled' if pages|length <= 1 else '' }}>✕</button>
</span> </span>
</div> </div>
{% endfor %} {% endfor %}
@@ -207,8 +210,8 @@
<div class="docPage" id="docPage"> <div class="docPage" id="docPage">
<div class="docPageContent" <div class="docPageContent"
id="docPageContent" id="docPageContent"
data-vertical-align="{{ active_page.vertical_align }}">{{ rendered_document|safe }}</div> data-vertical-align="{{ active_page.vertical_align if active_page else 'top' }}">{% if active_page %}{{ rendered_document|safe }}{% else %}<div class="docEmptyState docEmptyPageState">Aucune page — cliquez sur « + Ajouter une page » pour commencer.</div>{% endif %}</div>
{# //NOSONAR S5247 - rendered_document vient de document_engine.render_document, qui échappe (html.escape) tout contenu utilisateur avant interpolation (voir document_engine/rendering/render_document_element.py) ; jamais de HTML brut non échappé ici #} {# //NOSONAR S5247 - rendered_document vient de document_engine.render_document, qui échappe (html.escape) tout contenu utilisateur avant interpolation (voir document_engine/rendering/render_document_element.py) ; jamais de HTML brut non échappé ici. Le message "Aucune page" est un littéral, jamais un contenu utilisateur. #}
</div> </div>
<button type="button" <button type="button"
class="docPreviewExitBtn" class="docPreviewExitBtn"
@@ -285,7 +288,7 @@ window.FORGE_DOCUMENT = {
elements: {{ elements|tojson }}, elements: {{ elements|tojson }},
elementKindLabels: {{ element_kind_labels|tojson }}, elementKindLabels: {{ element_kind_labels|tojson }},
pages: {{ pages|tojson }}, pages: {{ pages|tojson }},
activePageId: {{ active_page.id|tojson }}, activePageId: {{ (active_page.id if active_page else none)|tojson }},
themes: {{ document_themes|tojson }}, themes: {{ document_themes|tojson }},
activeThemeId: {{ support.theme|tojson }} activeThemeId: {{ support.theme|tojson }}
}; };
+11 -1
View File
@@ -205,12 +205,22 @@ def support(client: FlaskClient) -> Generator[str, None, None]:
"""Crée un support de formation de test frais via /documents/new """Crée un support de formation de test frais via /documents/new
(mirroir de la fixture `game` ci-dessus, pour l'autre type de projet — (mirroir de la fixture `game` ci-dessus, pour l'autre type de projet —
entité racine séparée, voir docs/plan/PLAN.md) et le supprime après le entité racine séparée, voir docs/plan/PLAN.md) et le supprime après le
test, quel que soit son résultat.""" test, quel que soit son résultat.
Un support neuf n'a plus aucune page par défaut (retour utilisateur
du 26/09/2026, voir document_engine/pages/pages.md) — cette fixture
lui en crée une par convénience, puisque la quasi-totalité des tests
existants portent sur du contenu et présupposent une première page
"Page 1" déjà là (comme avant ce changement). Les tests qui portent
spécifiquement sur l'état "0 page" créent leur propre support brut
via db.create_support(...) plutôt que d'utiliser cette fixture."""
import db.supports import db.supports
import document_engine
resp = client.post("/documents/new", data={"name": "pytest_test_support"}, follow_redirects=False) resp = client.post("/documents/new", data={"name": "pytest_test_support"}, follow_redirects=False)
assert resp.status_code == 302 assert resp.status_code == 302
slug = resp.headers["Location"].rstrip("/").split("/")[-2] slug = resp.headers["Location"].rstrip("/").split("/")[-2]
document_engine.add_document_page(slug)
yield slug yield slug
if os.path.isdir(db.supports.support_dir(slug)): if os.path.isdir(db.supports.support_dir(slug)):
db.delete_support(slug) db.delete_support(slug)
+195 -2
View File
@@ -16,9 +16,14 @@ from document_engine.rendering.box_style import default_border as doc_engine_box
def _new_support(tmp_support_slug_cleanup: Any, name: str = "Support de test") -> tuple[str, int]: def _new_support(tmp_support_slug_cleanup: Any, name: str = "Support de test") -> tuple[str, int]:
# db.create_support ne crée plus aucune page par défaut (retour
# utilisateur du 26/09/2026, voir document_engine/pages/pages.md) —
# ce helper en ajoute une explicitement, puisque tous les tests de ce
# fichier portent sur des éléments qui doivent bien appartenir à une
# page existante.
slug = db.create_support(name, owner_folder="90") slug = db.create_support(name, owner_folder="90")
tmp_support_slug_cleanup(slug) tmp_support_slug_cleanup(slug)
page_id = doc_engine.list_document_pages(slug)[0]["id"] page_id = doc_engine.add_document_page(slug)
return slug, page_id return slug, page_id
@@ -468,7 +473,25 @@ def test_image_default_attributes_include_empty_svg_markup(tmp_support_slug_clea
element_id = doc_engine.add_document_element(slug, "image", page_id=page_id) element_id = doc_engine.add_document_element(slug, "image", page_id=page_id)
element = doc_engine.get_document_element(slug, element_id) element = doc_engine.get_document_element(slug, element_id)
assert element is not None assert element is not None
assert element["attributes"] == {"src": "", "alt": "", "svg_markup": ""} attrs = element["attributes"]
assert attrs["src"] == ""
assert attrs["alt"] == ""
assert attrs["svg_markup"] == ""
# Cadre FIXE par défaut (retour utilisateur du 26/09/2026 : "ce cadre
# ne devrait pas changer de taille en fonction de la taille de
# l'image mais être fixe et contraindre l'image dedans") — une photo
# importée est TOUJOURS rognée pour remplir ce cadre par défaut ;
# "Taille réelle" (object_fit="") reste un choix explicite possible
# via le panneau Propriétés, jamais le défaut d'un nouvel élément.
assert attrs["object_fit"] == "cover"
assert attrs["height"] == "220px"
assert attrs["aspect_ratio"] == ""
assert attrs["filter_preset"] == ""
assert attrs["click_behavior"] == ""
assert attrs["link_url"] == ""
assert attrs["lazy_load"] is False
assert attrs["caption"] == ""
assert attrs["border"] == doc_engine_box_default_border()
def test_image_with_svg_markup_takes_priority_over_src(tmp_support_slug_cleanup: Any) -> None: def test_image_with_svg_markup_takes_priority_over_src(tmp_support_slug_cleanup: Any) -> None:
@@ -499,6 +522,176 @@ def test_image_svg_markup_strips_script_tag(tmp_support_slug_cleanup: Any) -> No
assert "alert(" not in html assert "alert(" not in html
def test_image_object_fit_renders_only_known_values(tmp_support_slug_cleanup: Any) -> None:
slug, page_id = _new_support(tmp_support_slug_cleanup)
element_id = doc_engine.add_document_element(slug, "image", page_id=page_id)
doc_engine.update_document_element_attributes(
slug, element_id, {"src": "https://exemple.test/a.png", "alt": "", "object_fit": "cover"}
)
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
assert "object-fit:cover;" in html
doc_engine.update_document_element_attributes(
slug, element_id, {"src": "https://exemple.test/a.png", "alt": "", "object_fit": "n-importe-quoi"}
)
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
assert "object-fit" not in html
def test_image_aspect_ratio_renders_and_is_escaped(tmp_support_slug_cleanup: Any) -> None:
slug, page_id = _new_support(tmp_support_slug_cleanup)
element_id = doc_engine.add_document_element(slug, "image", page_id=page_id)
doc_engine.update_document_element_attributes(
slug, element_id, {"src": "https://exemple.test/a.png", "alt": "", "aspect_ratio": "16 / 9"}
)
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
assert "aspect-ratio:16 / 9;" in html
def test_image_filter_preset_maps_to_a_real_css_filter(tmp_support_slug_cleanup: Any) -> None:
slug, page_id = _new_support(tmp_support_slug_cleanup)
element_id = doc_engine.add_document_element(slug, "image", page_id=page_id)
doc_engine.update_document_element_attributes(
slug, element_id, {"src": "https://exemple.test/a.png", "alt": "", "filter_preset": "grayscale"}
)
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
assert "filter:grayscale(1);" in html
def test_image_lazy_load_adds_the_loading_attribute(tmp_support_slug_cleanup: Any) -> None:
slug, page_id = _new_support(tmp_support_slug_cleanup)
element_id = doc_engine.add_document_element(slug, "image", page_id=page_id)
doc_engine.update_document_element_attributes(
slug, element_id, {"src": "https://exemple.test/a.png", "alt": "", "lazy_load": True}
)
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
assert 'loading="lazy"' in html
def test_image_caption_wraps_in_a_figure(tmp_support_slug_cleanup: Any) -> None:
slug, page_id = _new_support(tmp_support_slug_cleanup)
element_id = doc_engine.add_document_element(slug, "image", page_id=page_id)
doc_engine.update_document_element_attributes(
slug, element_id, {"src": "https://exemple.test/a.png", "alt": "", "caption": "Une légende <script>x</script>"}
)
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
assert '<figure class="docImageFigure">' in html
assert '<figcaption class="docImageCaption">' in html
assert "<script>" not in html
assert "&lt;script&gt;" in html
def test_image_click_behavior_link_wraps_in_an_anchor(tmp_support_slug_cleanup: Any) -> None:
slug, page_id = _new_support(tmp_support_slug_cleanup)
element_id = doc_engine.add_document_element(slug, "image", page_id=page_id)
doc_engine.update_document_element_attributes(
slug,
element_id,
{
"src": "https://exemple.test/a.png",
"alt": "",
"click_behavior": "link",
"link_url": "https://exemple.test/cible",
},
)
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
assert (
'<a class="docImageLink" href="https://exemple.test/cible" target="_blank" rel="noopener noreferrer">' in html
)
def test_image_click_behavior_link_without_url_does_not_wrap(tmp_support_slug_cleanup: Any) -> None:
slug, page_id = _new_support(tmp_support_slug_cleanup)
element_id = doc_engine.add_document_element(slug, "image", page_id=page_id)
doc_engine.update_document_element_attributes(
slug, element_id, {"src": "https://exemple.test/a.png", "alt": "", "click_behavior": "link", "link_url": ""}
)
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
assert "docImageLink" not in html
def test_image_click_behavior_lightbox_wraps_in_a_trigger_div(tmp_support_slug_cleanup: Any) -> None:
slug, page_id = _new_support(tmp_support_slug_cleanup)
element_id = doc_engine.add_document_element(slug, "image", page_id=page_id)
doc_engine.update_document_element_attributes(
slug, element_id, {"src": "https://exemple.test/a.png", "alt": "", "click_behavior": "lightbox"}
)
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
assert '<div class="docImageLightboxTrigger">' in html
def test_image_box_style_attributes_render_and_placeholder_gets_them_too(tmp_support_slug_cleanup: Any) -> None:
slug, page_id = _new_support(tmp_support_slug_cleanup)
element_id = doc_engine.add_document_element(slug, "image", page_id=page_id)
border = doc_engine_box_default_border()
border["left"] = {"style": "dotted", "width": "2px", "color": "#00ff00"}
doc_engine.update_document_element_attributes(
slug,
element_id,
{
"src": "",
"alt": "",
"padding": "8px",
"border_radius": "20px",
"width": "300px",
"border": border,
},
)
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
assert "docImagePlaceholder" in html
assert "padding:8px;" in html
assert "border-radius:20px;" in html
assert "width:300px;" in html
assert "border-left:2px dotted #00ff00;" in html
def test_image_box_style_targets_the_figure_when_a_caption_is_present(tmp_support_slug_cleanup: Any) -> None:
# Bug réel constaté le 26/09/2026 : "la position de bloc ne fonctionne
# pas sur l'image" — align-self (et le reste de render_box_style) doit
# se poser sur l'élément RÉELLEMENT top-niveau (l'enfant direct du
# flex-column de la page), jamais sur l'<img> interne quand une
# légende l'enveloppe : sans quoi align-self n'a aucun effet visuel.
slug, page_id = _new_support(tmp_support_slug_cleanup)
element_id = doc_engine.add_document_element(slug, "image", page_id=page_id)
doc_engine.update_document_element_attributes(
slug,
element_id,
{
"src": "https://exemple.test/a.png",
"alt": "",
"caption": "Légende",
"align_self": "center",
"width": "300px",
},
)
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
assert '<figure class="docImageFigure" style="width:300px; align-self:center;">' in html
# align-self ne doit apparaître qu'UNE fois (sur la figure), jamais
# aussi sur l'<img> interne (ce qui n'aurait aucun effet CSS).
assert html.count("align-self") == 1
def test_image_box_style_targets_the_link_wrapper_when_no_caption(tmp_support_slug_cleanup: Any) -> None:
slug, page_id = _new_support(tmp_support_slug_cleanup)
element_id = doc_engine.add_document_element(slug, "image", page_id=page_id)
doc_engine.update_document_element_attributes(
slug,
element_id,
{
"src": "https://exemple.test/a.png",
"alt": "",
"click_behavior": "link",
"link_url": "https://exemple.test/cible",
"align_self": "center",
},
)
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
assert (
'<a class="docImageLink" href="https://exemple.test/cible" target="_blank" rel="noopener noreferrer" '
'style="align-self:center;">' in html
)
def test_bouton_default_attributes_include_empty_attachment_fields(tmp_support_slug_cleanup: Any) -> None: def test_bouton_default_attributes_include_empty_attachment_fields(tmp_support_slug_cleanup: Any) -> None:
slug, page_id = _new_support(tmp_support_slug_cleanup) slug, page_id = _new_support(tmp_support_slug_cleanup)
element_id = doc_engine.add_document_element(slug, "bouton", page_id=page_id) element_id = doc_engine.add_document_element(slug, "bouton", page_id=page_id)
+26 -6
View File
@@ -1,7 +1,10 @@
"""Routes HTTP des pages d'un support de formation (routes/document/ """Routes HTTP des pages d'un support de formation (routes/document/
document_page_*.py) — retour utilisateur du 21/09/2026 : "il faut document_page_*.py) — retour utilisateur du 21/09/2026 : "il faut
implémenter un système de page". Un support a toujours au moins une page implémenter un système de page". La fixture `support` crée une première
(créée par défaut avec le support, voir la fixture `support`).""" page par convénience pour ces tests (voir tests/conftest.py) ; en
production un support neuf n'a plus aucune page, et supprimer la
dernière page restante est désormais autorisé (retour utilisateur du
26/09/2026, voir document_engine/pages/pages.md)."""
from flask.testing import FlaskClient from flask.testing import FlaskClient
@@ -50,12 +53,29 @@ def test_document_page_rename_rejects_an_unknown_page(client: FlaskClient, suppo
assert client.post(f"/document/{support}/pages/999/rename", json={"title": "X"}).status_code == 404 assert client.post(f"/document/{support}/pages/999/rename", json={"title": "X"}).status_code == 404
def test_document_page_delete_refuses_the_last_remaining_page(client: FlaskClient, support: str) -> None: def test_document_page_delete_allows_deleting_the_last_remaining_page(client: FlaskClient, support: str) -> None:
page_id = _page_id(support) page_id = _page_id(support)
resp = client.post(f"/document/{support}/pages/{page_id}/delete") resp = client.post(f"/document/{support}/pages/{page_id}/delete")
assert resp.status_code == 400 assert resp.status_code == 200
assert "error" in resp.get_json() assert resp.get_json()["ok"] is True
assert document_engine.get_document_page(support, page_id) is not None assert document_engine.get_document_page(support, page_id) is None
assert document_engine.list_document_pages(support) == []
def test_document_page_delete_all_wipes_every_page_and_its_elements(client: FlaskClient, support: str) -> None:
first_page_id = _page_id(support)
second_page_id = client.post(f"/document/{support}/pages/add", json={}).get_json()["id"]
element_resp = client.post(f"/document/{support}/elements/add", data={"kind": "titre", "page_id": second_page_id})
element_id = element_resp.get_json()["id"]
resp = client.post(f"/document/{support}/pages/delete-all")
assert resp.status_code == 200
assert resp.get_json()["ok"] is True
assert document_engine.list_document_pages(support) == []
assert document_engine.get_document_page(support, first_page_id) is None
assert document_engine.get_document_page(support, second_page_id) is None
assert document_engine.get_document_element(support, element_id) is None
def test_document_page_delete_removes_a_non_last_page_and_its_elements(client: FlaskClient, support: str) -> None: def test_document_page_delete_removes_a_non_last_page_and_its_elements(client: FlaskClient, support: str) -> None:
+79
View File
@@ -39,6 +39,19 @@ def test_document_edit_renders_directly_no_intermediate_page(client: FlaskClient
assert resp.status_code == 200 assert resp.status_code == 200
def test_document_edit_works_on_a_support_with_zero_pages(client: FlaskClient, support: str) -> None:
# Retour utilisateur du 26/09/2026 : "l'éditeur ne dois plus etre
# obliger d'avoir une page active ou créer, il peut etre ouvert sans
# aucune page" — un support neuf (ou vidé via "Supprimer toutes les
# pages") n'a plus de page active, l'éditeur ne doit pas planter.
client.post(f"/document/{support}/pages/delete-all")
resp = client.get(f"/document/{support}/edit")
assert resp.status_code == 200
html = resp.get_data(as_text=True)
assert "activePageId: null" in html
assert "pages: []" in html
def test_document_element_add_returns_rendered_html(client: FlaskClient, support: str) -> None: def test_document_element_add_returns_rendered_html(client: FlaskClient, support: str) -> None:
resp = client.post(f"/document/{support}/elements/add", data={"kind": "titre", "page_id": _page_id(support)}) resp = client.post(f"/document/{support}/elements/add", data={"kind": "titre", "page_id": _page_id(support)})
assert resp.status_code == 200 assert resp.status_code == 200
@@ -417,6 +430,72 @@ def test_download_attachment_404s_when_nothing_was_uploaded(client: FlaskClient,
assert resp.status_code == 404 assert resp.status_code == 404
def _add_image(client: FlaskClient, support: str) -> int:
resp = client.post(f"/document/{support}/elements/add", data={"kind": "image", "page_id": _page_id(support)})
return int(resp.get_json()["id"])
def test_upload_image_sets_src_and_clears_svg_markup(client: FlaskClient, support: str) -> None:
element_id = _add_image(client, support)
document_engine.update_document_element_attributes(support, element_id, {"svg_markup": "<svg></svg>"})
resp = client.post(
f"/document/{support}/elements/{element_id}/upload-image",
data={"file": (io.BytesIO(b"fake png bytes"), "photo.png")},
content_type="multipart/form-data",
)
assert resp.status_code == 200
payload = resp.get_json()
assert payload["ok"] is True
assert payload["attributes"]["svg_markup"] == ""
src = payload["attributes"]["src"]
assert f"/document/{support}/uploads/" in src
assert f'src="{src}"' in payload["rendered_html"]
def test_upload_image_rejects_a_non_image_element(client: FlaskClient, support: str) -> None:
element_id = _add_bouton(client, support)
resp = client.post(
f"/document/{support}/elements/{element_id}/upload-image",
data={"file": (io.BytesIO(b"peu importe"), "x.png")},
content_type="multipart/form-data",
)
assert resp.status_code == 400
def test_upload_image_rejects_a_missing_file(client: FlaskClient, support: str) -> None:
element_id = _add_image(client, support)
resp = client.post(f"/document/{support}/elements/{element_id}/upload-image", data={})
assert resp.status_code == 400
def test_upload_image_rejects_a_disallowed_extension(client: FlaskClient, support: str) -> None:
element_id = _add_image(client, support)
resp = client.post(
f"/document/{support}/elements/{element_id}/upload-image",
data={"file": (io.BytesIO(b"#!/bin/sh"), "script.sh")},
content_type="multipart/form-data",
)
assert resp.status_code == 400
def test_uploaded_file_serves_the_stored_image(client: FlaskClient, support: str) -> None:
element_id = _add_image(client, support)
resp = client.post(
f"/document/{support}/elements/{element_id}/upload-image",
data={"file": (io.BytesIO(b"fake png bytes"), "photo.png")},
content_type="multipart/form-data",
)
src = resp.get_json()["attributes"]["src"]
resp = client.get(src)
assert resp.status_code == 200
assert resp.data == b"fake png bytes"
def test_uploaded_file_404s_for_an_unknown_filename(client: FlaskClient, support: str) -> None:
resp = client.get(f"/document/{support}/uploads/inconnu.png")
assert resp.status_code == 404
def test_document_edit_has_no_theme_link_by_default(client: FlaskClient, support: str) -> None: def test_document_edit_has_no_theme_link_by_default(client: FlaskClient, support: str) -> None:
resp = client.get(f"/document/{support}/edit") resp = client.get(f"/document/{support}/edit")
assert "document/themes/securite-incendie.css" not in resp.get_data(as_text=True) assert "document/themes/securite-incendie.css" not in resp.get_data(as_text=True)
+10
View File
@@ -16,6 +16,16 @@ def test_create_support_creates_its_own_db_file_and_schema(tmp_support_slug_clea
assert meta["name"] == "Sécurité incendie" assert meta["name"] == "Sécurité incendie"
def test_new_support_has_no_page_by_default(tmp_support_slug_cleanup: Any) -> None:
# Retour utilisateur du 26/09/2026 : "l'éditeur ne dois plus etre
# obliger d'avoir une page active" — voir document_engine/pages/pages.md.
import document_engine
slug = db.create_support("Nouveau projet", owner_folder="52")
tmp_support_slug_cleanup(slug)
assert document_engine.list_document_pages(slug) == []
def test_list_supports_scopes_to_owner_and_excludes_games( def test_list_supports_scopes_to_owner_and_excludes_games(
tmp_support_slug_cleanup: Any, tmp_game_slug_cleanup: Any tmp_support_slug_cleanup: Any, tmp_game_slug_cleanup: Any
) -> None: ) -> None: