diff --git a/db/supports/create_support.py b/db/supports/create_support.py index 93140aa1..971d0c79 100644 --- a/db/supports/create_support.py +++ b/db/supports/create_support.py @@ -39,6 +39,7 @@ def create_support(name: str, owner_folder: str) -> str: id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL DEFAULT 'Page 1', order_index INTEGER NOT NULL DEFAULT 0, + vertical_align TEXT NOT NULL DEFAULT 'top', created_at TEXT DEFAULT CURRENT_TIMESTAMP ); diff --git a/db/supports/ensure_document_pages_schema.py b/db/supports/ensure_document_pages_schema.py index 920396e6..c6eb7673 100644 --- a/db/supports/ensure_document_pages_schema.py +++ b/db/supports/ensure_document_pages_schema.py @@ -24,6 +24,15 @@ def ensure_document_pages_schema(slug: str) -> None: 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 + # (top/center/bottom — voir document_engine/pages/ + # set_document_page_vertical_align.py), ajouté après la création + # initiale des pages — même pragmatisme que page_id ci-dessous, une + # valeur par défaut CONSTANTE plutôt qu'une contrainte CHECK. + page_cols = {r["name"] for r in conn.execute("PRAGMA table_info(_document_pages)").fetchall()} + if "vertical_align" not in page_cols: + conn.execute("ALTER TABLE _document_pages ADD COLUMN vertical_align TEXT NOT NULL DEFAULT 'top'") + cols = {r["name"] for r in conn.execute("PRAGMA table_info(_document_elements)").fetchall()} if "page_id" not in cols: first_page_id = conn.execute("SELECT id FROM _document_pages ORDER BY order_index LIMIT 1").fetchone()["id"] diff --git a/document_engine/__init__.py b/document_engine/__init__.py index e3608a88..2ed1e220 100644 --- a/document_engine/__init__.py +++ b/document_engine/__init__.py @@ -76,6 +76,7 @@ from .pages.get_document_page import get_document_page from .pages.list_document_pages import list_document_pages from .pages.move_document_page import move_document_page from .pages.replace_document_content import replace_document_content +from .pages.set_document_page_vertical_align import VERTICAL_ALIGNS, set_document_page_vertical_align from .pages.update_document_page import update_document_page from .rendering.render_document_element import render_document, render_document_element from .rendering.sanitize_svg_markup import sanitize_svg_markup @@ -105,6 +106,7 @@ __all__ = [ "MIN_TIMER_SECONDS", "MIN_WORDS", "MINIGAME_KINDS", + "VERTICAL_ALIGNS", "add_document_element", "add_document_page", "delete_document_element", @@ -130,6 +132,7 @@ __all__ = [ "sanitize_scenario_config", "sanitize_svg_markup", "seed_blocks_to_elements", + "set_document_page_vertical_align", "update_document_element_attributes", "update_document_page", ] diff --git a/document_engine/pages/pages.md b/document_engine/pages/pages.md index 84c9d8fd..309a6182 100644 --- a/document_engine/pages/pages.md +++ b/document_engine/pages/pages.md @@ -43,21 +43,42 @@ décalage un par un). - **Retour** : aucun. - **Exceptions** : aucune. -## `replace_document_content(slug: str, seed_pages: list[list[dict[str, Any]]]) -> None` +## `set_document_page_vertical_align(slug: str, page_id: int, vertical_align: str) -> None` +Règle l'alignement vertical du CONTENU d'une page (`justify-content` de +`.docPageContent`, voir `static/document/document-editor.css`) — +réglable depuis le panneau Propriétés quand l'onglet "Pages" de +l'éditeur est actif (retour utilisateur du 24/09/2026 : "quand je suis +sur l'onglet page, dans les propriétés s'affiche l'option de +l'alignement de la page"). Une valeur hors de `VERTICAL_ALIGNS` +(`"top"`/`"center"`/`"bottom"`) retombe silencieusement sur `"top"`, +même philosophie défensive que `update_document_page` pour un titre +vide. +- **Retour** : aucun. +- **Exceptions** : aucune. + +### `VERTICAL_ALIGNS: tuple[str, ...]` +`("top", "center", "bottom")` — valeurs valides de `vertical_align`, +`"top"` étant la valeur par défaut posée en base (voir +`db/supports/create_support.py`/`ensure_document_pages_schema.py`). + +## `replace_document_content(slug: str, seed_pages: list[dict[str, Any]]) -> None` Remplace TOUT le contenu du support par `seed_pages` — utilisée UNIQUEMENT quand le créateur choisit "utiliser le contenu du modèle" en appliquant un thème (voir `routes/document/document_theme_apply.py` et `document_engine/themes/`), jamais appelée sans confirmation explicite côté client (action destructive, irréversible côté serveur). `seed_pages` -est une liste de pages, chaque page une liste de blocs -`{"kind", "attributes", "children"}` (`children` optionnel, uniquement -pour un bloc `kind="row"` — un seul niveau de profondeur, comme le moteur -de rangées lui-même). Les attributs fournis sont fusionnés sur -`element_default_attributes(kind)`, jamais un remplacement brut. Les -nouvelles pages sont créées AVANT que les anciennes soient supprimées -(jamais l'inverse) : passer par zéro page, même brièvement, déclenche le -filet de sécurité de `ensure_document_pages_schema` (un support a -toujours au moins une page), qui recréerait une "Page 1" vide parasite. +est une liste de pages, chaque page un dict +`{"vertical_align": "top"|"center"|"bottom", "blocks": [...]}` +(`vertical_align` optionnel, retombe sur `"top"`) ; chaque bloc de +`blocks` est `{"kind", "attributes", "children"}` (`children` optionnel, +uniquement pour un bloc `kind="row"` — un seul niveau de profondeur, +comme le moteur de rangées lui-même). Les attributs fournis sont +fusionnés sur `element_default_attributes(kind)`, jamais un remplacement +brut. Les nouvelles pages sont créées AVANT que les anciennes soient +supprimées (jamais l'inverse) : passer par zéro page, même brièvement, +déclenche le filet de sécurité de `ensure_document_pages_schema` (un +support a toujours au moins une page), qui recréerait une "Page 1" vide +parasite. - **Retour** : aucun. - **Exceptions** : aucune levée explicitement. diff --git a/document_engine/pages/replace_document_content.py b/document_engine/pages/replace_document_content.py index 0637e541..79a21e47 100644 --- a/document_engine/pages/replace_document_content.py +++ b/document_engine/pages/replace_document_content.py @@ -6,24 +6,28 @@ from ..labels.element_kind_labels import element_default_attributes from .add_document_page import add_document_page from .delete_document_page import delete_document_page from .list_document_pages import list_document_pages +from .set_document_page_vertical_align import set_document_page_vertical_align -def replace_document_content(slug: str, seed_pages: list[list[dict[str, Any]]]) -> None: +def replace_document_content(slug: str, seed_pages: list[dict[str, Any]]) -> None: """Remplace TOUT le contenu du support par `seed_pages` — utilisé UNIQUEMENT quand le créateur choisit explicitement "utiliser le contenu du modèle" en appliquant un thème (voir routes/document/ document_theme_apply.py, jamais appelée sans confirmation explicite côté client : action destructive, irréversible côté serveur). - `seed_pages` est une liste de pages, chaque page une liste de blocs - `{"kind": str, "attributes": dict, "children": [...]}` (`children` - optionnel, uniquement pour un bloc `kind="row"` — chaque enfant a la - même forme `{"kind", "attributes"}`, sans petit-enfant : le moteur de - rangées ne descend jamais à plus d'un niveau, voir document_engine/ - rendering/render_document_element.py::_render_row). Les attributs - fournis sont FUSIONNÉS sur element_default_attributes(kind) (jamais un - remplacement brut) pour rester valides même si `seed_pages` n'en - précise qu'une partie.""" + `seed_pages` est une liste de pages, chaque page un dict + `{"vertical_align": "top"|"center"|"bottom", "blocks": [...]}` + (`vertical_align` optionnel, retombe sur "top" — voir + set_document_page_vertical_align.VERTICAL_ALIGNS) ; chaque bloc de + `blocks` est `{"kind": str, "attributes": dict, "children": [...]}` + (`children` optionnel, uniquement pour un bloc `kind="row"` — chaque + enfant a la même forme `{"kind", "attributes"}`, sans petit-enfant : + le moteur de rangées ne descend jamais à plus d'un niveau, voir + document_engine/rendering/render_document_element.py::_render_row). + Les attributs fournis sont FUSIONNÉS sur + element_default_attributes(kind) (jamais un remplacement brut) pour + 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 # (jamais l'inverse) : passer par zéro page, même un court instant, # déclenche le filet de sécurité de ensure_document_pages_schema (un @@ -31,9 +35,12 @@ def replace_document_content(slug: str, seed_pages: list[list[dict[str, Any]]]) # vide juste avant l'ajout de la première page du thème — bug réel # rencontré en écrivant les tests de cette fonction. old_page_ids = [page["id"] for page in list_document_pages(slug)] - for page_blocks in seed_pages: + for seed_page in seed_pages: page_id = add_document_page(slug) - for block in page_blocks: + vertical_align = seed_page.get("vertical_align") + if vertical_align: + set_document_page_vertical_align(slug, page_id, vertical_align) + for block in seed_page.get("blocks", []): _add_seed_block(slug, page_id, block, parent_id=None) for old_page_id in old_page_ids: delete_document_page(slug, old_page_id) diff --git a/document_engine/pages/set_document_page_vertical_align.py b/document_engine/pages/set_document_page_vertical_align.py new file mode 100644 index 00000000..6e2c4984 --- /dev/null +++ b/document_engine/pages/set_document_page_vertical_align.py @@ -0,0 +1,20 @@ +from db.supports import connect_support, ensure_document_pages_schema + +VERTICAL_ALIGNS = ("top", "center", "bottom") + + +def set_document_page_vertical_align(slug: str, page_id: int, vertical_align: str) -> None: + """Règle l'alignement vertical du CONTENU d'une page (`justify-content` + de `.docPageContent`, voir static/document/document-editor.css) — + réglable depuis le panneau Propriétés quand l'onglet "Pages" est actif + (retour utilisateur du 24/09/2026), jamais un attribut par élément (ça + concerne la page entière, pas un bloc de contenu particulier). Une + valeur hors de `VERTICAL_ALIGNS` retombe silencieusement sur "top" + (état par défaut) plutôt que de lever — même philosophie défensive que + `update_document_page` pour un titre vide.""" + ensure_document_pages_schema(slug) + clean_align = vertical_align if vertical_align in VERTICAL_ALIGNS else "top" + conn = connect_support(slug) + conn.execute("UPDATE _document_pages SET vertical_align = ? WHERE id = ?", (clean_align, page_id)) + conn.commit() + conn.close() diff --git a/document_engine/themes/securite_incendie_seed.py b/document_engine/themes/securite_incendie_seed.py index 583054a7..b977c79d 100644 --- a/document_engine/themes/securite_incendie_seed.py +++ b/document_engine/themes/securite_incendie_seed.py @@ -8,171 +8,193 @@ modifiables ensuite comme n'importe quel contenu créé à la main.""" from typing import Any -SECURITE_INCENDIE_SEED_PAGES: list[list[dict[str, Any]]] = [ - # Page 1 — Titre - [ - {"kind": "badge", "attributes": {"content": "Module obligatoire"}}, - {"kind": "titre", "attributes": {"content": "Formation Sécurité Incendie", "style": "titre1"}}, - { - "kind": "paragraphe", - "attributes": { - "content": ( - "Reconnaître les risques, réagir dans les 3 premières minutes, protéger les " - "personnes autour de vous. Durée : 25 min · à renouveler tous les 24 mois." - ) +SECURITE_INCENDIE_SEED_PAGES: list[dict[str, Any]] = [ + # Page 1 — Titre (contenu centré verticalement, comme une page de + # garde — retour utilisateur du 24/09/2026) + { + "vertical_align": "center", + "blocks": [ + {"kind": "badge", "attributes": {"content": "Module obligatoire"}}, + {"kind": "titre", "attributes": {"content": "Formation Sécurité Incendie", "style": "titre1"}}, + { + "kind": "paragraphe", + "attributes": { + "content": ( + "Reconnaître les risques, réagir dans les 3 premières minutes, protéger les " + "personnes autour de vous. Durée : 25 min · à renouveler tous les 24 mois." + ) + }, }, - }, - ], + ], + }, # Page 2 — Objectifs - [ - {"kind": "titre", "attributes": {"content": "À l'issue de ce module, vous saurez", "style": "titre2"}}, - { - "kind": "liste_puces", - "attributes": { - "items": [ - "Identifier les causes principales d'un départ de feu sur un poste de travail", - "Choisir le bon type d'extincteur selon la classe de feu rencontrée", - "Déclencher l'alarme et évacuer en moins de 3 minutes", - ] + { + "blocks": [ + {"kind": "titre", "attributes": {"content": "À l'issue de ce module, vous saurez", "style": "titre2"}}, + { + "kind": "liste_puces", + "attributes": { + "items": [ + "Identifier les causes principales d'un départ de feu sur un poste de travail", + "Choisir le bon type d'extincteur selon la classe de feu rencontrée", + "Déclencher l'alarme et évacuer en moins de 3 minutes", + ] + }, }, - }, - { - "kind": "badge", - "attributes": { - "content": ( - "Un feu peut doubler de volume toutes les 30 secondes — la rapidité de " - "réaction compte autant que le geste." - ) + { + "kind": "badge", + "attributes": { + "content": ( + "Un feu peut doubler de volume toutes les 30 secondes — la rapidité de " + "réaction compte autant que le geste." + ) + }, }, - }, - ], + ], + }, # Page 3 — Classes de feu - [ - {"kind": "titre", "attributes": {"content": "Les 5 classes de feu", "style": "titre2"}}, - { - "kind": "paragraphe", - "attributes": { - "content": ( - "Chaque classe correspond à un combustible différent — le mauvais " - "extincteur peut aggraver l'incendie." - ) - }, - }, - { - "kind": "row", - "attributes": {"gap": 10, "align": "stretch", "justify": "flex-start"}, - "children": [ - { - "kind": "carte", - "attributes": {"label": "A", "title": "Feux secs", "description": "Bois, papier, tissus"}, + { + "blocks": [ + {"kind": "titre", "attributes": {"content": "Les 5 classes de feu", "style": "titre2"}}, + { + "kind": "paragraphe", + "attributes": { + "content": ( + "Chaque classe correspond à un combustible différent — le mauvais " + "extincteur peut aggraver l'incendie." + ) }, - { - "kind": "carte", - "attributes": {"label": "B", "title": "Liquides", "description": "Essence, solvants"}, - }, - {"kind": "carte", "attributes": {"label": "C", "title": "Gaz", "description": "Butane, propane"}}, - {"kind": "carte", "attributes": {"label": "D", "title": "Métaux", "description": "Sodium, magnésium"}}, - { - "kind": "carte", - "attributes": {"label": "F", "title": "Huiles", "description": "Friteuses, graisses"}, - }, - ], - }, - { - "kind": "badge", - "attributes": { - "content": ( - "⚠ Un feu électrique n'est pas une classe à part : coupez toujours le courant avant d'intervenir." - ) }, - }, - ], - # Page 4 — Méthode P.A.S.S. - [ - {"kind": "titre", "attributes": {"content": "Utiliser un extincteur : la méthode P.A.S.S.", "style": "titre2"}}, - { - "kind": "liste_numerotee", - "attributes": { - "items": [ - "Percuter — retirez la goupille de sécurité", - "Approcher — à 2 ou 3 mètres du foyer", - "Serrer — pressions courtes, pas en continu", - "Balayer — la base des flammes, gauche à droite", - ] - }, - }, - { - "kind": "row", - "attributes": {"gap": 12, "align": "center", "justify": "space-between"}, - "children": [ - { - "kind": "badge", - "attributes": {"content": "⚠ Jamais d'eau sur un feu électrique ou une friteuse en feu."}, - }, - {"kind": "bouton", "attributes": {"label": "Fiche consignes", "target": ""}}, - ], - }, - ], - # Page 5 — Évacuation - [ - {"kind": "titre", "attributes": {"content": "Consignes d'évacuation", "style": "titre2"}}, - { - "kind": "paragraphe", - "attributes": {"content": "Dès que l'alarme retentit, l'évacuation est immédiate — aucune exception."}, - }, - { - "kind": "liste_numerotee", - "attributes": { - "items": [ - "Cessez toute activité, laissez vos affaires sur place", - "Suivez le fléchage vers la sortie la plus proche, jamais l'ascenseur", - "Rejoignez le point de rassemblement, attendez l'appel de votre nom", - "Ne retournez jamais à l'intérieur avant le signal du responsable", - ] - }, - }, - ], - # Page 6 — Quiz (SEUL sur sa page : règle du moteur, voir - # routes/document/document_element_add.py — respectée ici dès la - # conception du contenu-seed, jamais contournée). - [ - { - "kind": "quiz", - "attributes": { - "theme_color": "#c8102e", - "timer_enabled": False, - "timer_seconds": 30, - "questions": [ + { + "kind": "row", + "attributes": {"gap": 10, "align": "stretch", "justify": "flex-start"}, + "children": [ { - "text": ( - "Quelle classe de feu concerne les liquides inflammables comme l'essence ou les solvants ?" - ), - "choices": ["Classe A", "Classe B", "Classe D"], - "correct_index": 1, - "points": 10, + "kind": "carte", + "attributes": {"label": "A", "title": "Feux secs", "description": "Bois, papier, tissus"}, }, { - "text": "Quel est le premier geste de la méthode P.A.S.S. ?", - "choices": [ - "Balayer la base des flammes", - "Percuter (retirer la goupille)", - "Approcher à 1 mètre", - ], - "correct_index": 1, - "points": 10, + "kind": "carte", + "attributes": {"label": "B", "title": "Liquides", "description": "Essence, solvants"}, + }, + {"kind": "carte", "attributes": {"label": "C", "title": "Gaz", "description": "Butane, propane"}}, + { + "kind": "carte", + "attributes": {"label": "D", "title": "Métaux", "description": "Sodium, magnésium"}, }, { - "text": "Que faire dès que l'alarme incendie retentit ?", - "choices": [ - "Terminer sa tâche puis sortir", - "Prendre l'ascenseur pour aller plus vite", - "Évacuer immédiatement par les issues de secours", - ], - "correct_index": 2, - "points": 10, + "kind": "carte", + "attributes": {"label": "F", "title": "Huiles", "description": "Friteuses, graisses"}, }, ], }, - } - ], + { + "kind": "badge", + "attributes": { + "content": ( + "⚠ Un feu électrique n'est pas une classe à part : coupez toujours le " + "courant avant d'intervenir." + ) + }, + }, + ], + }, + # Page 4 — Méthode P.A.S.S. + { + "blocks": [ + { + "kind": "titre", + "attributes": {"content": "Utiliser un extincteur : la méthode P.A.S.S.", "style": "titre2"}, + }, + { + "kind": "liste_numerotee", + "attributes": { + "items": [ + "Percuter — retirez la goupille de sécurité", + "Approcher — à 2 ou 3 mètres du foyer", + "Serrer — pressions courtes, pas en continu", + "Balayer — la base des flammes, gauche à droite", + ] + }, + }, + { + "kind": "row", + "attributes": {"gap": 12, "align": "center", "justify": "space-between"}, + "children": [ + { + "kind": "badge", + "attributes": {"content": "⚠ Jamais d'eau sur un feu électrique ou une friteuse en feu."}, + }, + {"kind": "bouton", "attributes": {"label": "Fiche consignes", "target": ""}}, + ], + }, + ], + }, + # Page 5 — Évacuation + { + "blocks": [ + {"kind": "titre", "attributes": {"content": "Consignes d'évacuation", "style": "titre2"}}, + { + "kind": "paragraphe", + "attributes": {"content": "Dès que l'alarme retentit, l'évacuation est immédiate — aucune exception."}, + }, + { + "kind": "liste_numerotee", + "attributes": { + "items": [ + "Cessez toute activité, laissez vos affaires sur place", + "Suivez le fléchage vers la sortie la plus proche, jamais l'ascenseur", + "Rejoignez le point de rassemblement, attendez l'appel de votre nom", + "Ne retournez jamais à l'intérieur avant le signal du responsable", + ] + }, + }, + ], + }, + # Page 6 — Quiz (SEUL sur sa page : règle du moteur, voir + # routes/document/document_element_add.py — respectée ici dès la + # conception du contenu-seed, jamais contournée). + { + "blocks": [ + { + "kind": "quiz", + "attributes": { + "theme_color": "#c8102e", + "timer_enabled": False, + "timer_seconds": 30, + "questions": [ + { + "text": ( + "Quelle classe de feu concerne les liquides inflammables comme " + "l'essence ou les solvants ?" + ), + "choices": ["Classe A", "Classe B", "Classe D"], + "correct_index": 1, + "points": 10, + }, + { + "text": "Quel est le premier geste de la méthode P.A.S.S. ?", + "choices": [ + "Balayer la base des flammes", + "Percuter (retirer la goupille)", + "Approcher à 1 mètre", + ], + "correct_index": 1, + "points": 10, + }, + { + "text": "Que faire dès que l'alarme incendie retentit ?", + "choices": [ + "Terminer sa tâche puis sortir", + "Prendre l'ascenseur pour aller plus vite", + "Évacuer immédiatement par les issues de secours", + ], + "correct_index": 2, + "points": 10, + }, + ], + }, + } + ], + }, ] diff --git a/document_engine/themes/themes.md b/document_engine/themes/themes.md index 86e43964..e94c7ec9 100644 --- a/document_engine/themes/themes.md +++ b/document_engine/themes/themes.md @@ -17,14 +17,15 @@ Un dict par thème : `id` (identifiant stable, utilisé dans les URLs et persisté via `db.set_document_theme`), `name`, `category`, `description` (affichage dans la modale), `css_path` (chemin sous `static/`, passé à `url_for('static', filename=...)`), `font_url` (optionnel, lien Google -Fonts), `seed_pages` (contenu de démonstration, voir -`document_engine.replace_document_content`). L'auteur d'un thème est -responsable de respecter les règles structurelles du moteur dans son -`seed_pages` (ex. un mini-jeu seul sur sa page — voir -`routes/document/document_element_add.py` — jamais revérifié -automatiquement puisque ce contenu vient du thème, pas de l'utilisateur ; -voir `tests/document/test_document_themes.py` pour la vérification -statique de cette règle sur tout le catalogue). +Fonts), `seed_pages` (contenu de démonstration — une liste de dicts +`{"vertical_align": "top"|"center"|"bottom", "blocks": [...]}`, voir +`document_engine.replace_document_content` pour la forme exacte de +`blocks`). L'auteur d'un thème est responsable de respecter les règles +structurelles du moteur dans son `seed_pages` (ex. un mini-jeu seul sur +sa page — voir `routes/document/document_element_add.py` — jamais +revérifié automatiquement puisque ce contenu vient du thème, pas de +l'utilisateur ; voir `tests/document/test_document_themes.py` pour la +vérification statique de cette règle sur tout le catalogue). ## `get_document_theme_entry(theme_id: str) -> dict[str, Any] | None` - **Retour** : l'entrée de `DOCUMENT_THEMES` dont `id == theme_id`, ou @@ -32,8 +33,9 @@ statique de cette règle sur tout le catalogue). - **Exceptions** : aucune. ## `seed_blocks_to_elements(blocks: list[dict[str, Any]]) -> list[dict[str, Any]]` -Convertit une liste de blocs de contenu-seed (`seed_pages[i]`) en une -liste d'éléments "à plat" (id/kind/parent_id/attributes) directement +Convertit une liste de blocs de contenu-seed (`seed_pages[i]["blocks"]`) +en une liste d'éléments "à plat" (id/kind/parent_id/attributes) +directement exploitable par `document_engine.render_document` — ids synthétiques NÉGATIFS, jamais persistés. Utilisée UNIQUEMENT pour l'aperçu d'un thème (voir `routes/document/document_theme_preview.py`, destiné à un diff --git a/routes/document/__init__.py b/routes/document/__init__.py index 0d3585e7..198c7213 100644 --- a/routes/document/__init__.py +++ b/routes/document/__init__.py @@ -18,6 +18,7 @@ from . import ( # noqa: F401 - enregistre les routes definies dans chaque modul document_page_delete, document_page_move, document_page_rename, + document_page_vertical_align, document_render, document_theme_apply, document_theme_preview, diff --git a/routes/document/document_page_vertical_align.py b/routes/document/document_page_vertical_align.py new file mode 100644 index 00000000..083f6cde --- /dev/null +++ b/routes/document/document_page_vertical_align.py @@ -0,0 +1,22 @@ +from flask import jsonify, request +from werkzeug.wrappers import Response + +import document_engine +from core.flask_app import app + + +@app.route("/document//pages//vertical-align", methods=["POST"]) +def document_page_vertical_align(slug: str, page_id: int) -> Response | tuple[Response, int]: + """Règle l'alignement vertical du contenu d'une page (voir panneau + Propriétés affiché quand l'onglet "Pages" est actif, static/document/ + js/document-editor.js::forgeDocRenderPageProps) — renvoie la valeur + RÉELLEMENT persistée (jamais celle envoyée telle quelle) : une valeur + invalide retombe silencieusement sur "top", même convention que + document_page_rename.py pour un titre vide.""" + if document_engine.get_document_page(slug, page_id) is None: + return jsonify({"error": "page introuvable"}), 404 + payload = request.get_json(force=True) or {} + document_engine.set_document_page_vertical_align(slug, page_id, str(payload.get("vertical_align", ""))) + page = document_engine.get_document_page(slug, page_id) + vertical_align = page["vertical_align"] if page else "top" + return jsonify({"ok": True, "vertical_align": vertical_align}) diff --git a/routes/document/document_render.py b/routes/document/document_render.py index 6ecc6994..857d65c4 100644 --- a/routes/document/document_render.py +++ b/routes/document/document_render.py @@ -20,10 +20,19 @@ def document_render(slug: str) -> Response | tuple[Response, int]: document_edit.py : le panneau Propriétés reçoit `data.elements` directement depuis cette route à chaque rafraîchissement du canevas.""" page_id = request.args.get("page_id", type=int) - if page_id is None or document_engine.get_document_page(slug, page_id) is None: + if page_id is None: + return jsonify({"error": "page introuvable"}), 404 + page = document_engine.get_document_page(slug, page_id) + if page is None: return jsonify({"error": "page introuvable"}), 404 elements = [ {**el, "attributes": document_engine.sanitize_element_attributes(el["kind"], el["attributes"])} for el in document_engine.list_document_elements(slug, page_id) ] - return jsonify({"html": document_engine.render_document(elements), "elements": elements}) + return jsonify( + { + "html": document_engine.render_document(elements), + "elements": elements, + "vertical_align": page["vertical_align"], + } + ) diff --git a/routes/document/document_theme_preview.py b/routes/document/document_theme_preview.py index c4bf0a7d..88b95a15 100644 --- a/routes/document/document_theme_preview.py +++ b/routes/document/document_theme_preview.py @@ -23,8 +23,11 @@ def document_theme_preview(slug: str, theme_id: str) -> str: theme = document_engine.get_document_theme_entry(theme_id) if theme is None: raise NotFound - rendered_pages = [ - document_engine.render_document(document_engine.seed_blocks_to_elements(page_blocks)) - for page_blocks in theme["seed_pages"] + pages = [ + { + "vertical_align": seed_page.get("vertical_align", "top"), + "html": document_engine.render_document(document_engine.seed_blocks_to_elements(seed_page["blocks"])), + } + for seed_page in theme["seed_pages"] ] - return render_template("document/document_theme_preview.html", theme=theme, rendered_pages=rendered_pages) + return render_template("document/document_theme_preview.html", theme=theme, pages=pages) diff --git a/static/document/document-editor.css b/static/document/document-editor.css index 8d467716..0c1f20ea 100644 --- a/static/document/document-editor.css +++ b/static/document/document-editor.css @@ -476,6 +476,20 @@ body.objectEditBody > main.content { flex-shrink: 0; } +/* Alignement vertical du contenu d'une page (retour utilisateur du + 24/09/2026 : réglable depuis le panneau Propriétés quand l'onglet + "Pages" est actif, voir document_engine.set_document_page_vertical_align + et static/document/js/document-editor.js::forgeDocRenderPageProps) — + "top" reste la valeur par défaut (justify-content:flex-start déjà + posé ci-dessus), aucune règle supplémentaire nécessaire pour elle. */ +.docPageContent[data-vertical-align="center"] { + justify-content: center; +} + +.docPageContent[data-vertical-align="bottom"] { + justify-content: flex-end; +} + .docPage.docPage--fixedWidth { --doc-page-width: var(--doc-preview-width, 760px); diff --git a/static/document/js/document-editor.js b/static/document/js/document-editor.js index 0e672853..0da1af14 100644 --- a/static/document/js/document-editor.js +++ b/static/document/js/document-editor.js @@ -169,7 +169,9 @@ function forgeDocCheckPageOverflow() { function forgeDocRefreshCanvas() { return forgeDocApiRender().then((data) => { - document.getElementById('docPageContent').innerHTML = data.html; + const contentEl = document.getElementById('docPageContent'); + contentEl.innerHTML = data.html; + contentEl.dataset.verticalAlign = data.vertical_align; window.forgeDocState.elements = data.elements; forgeDocRebuildIndex(); forgeDocBindCanvasInteractions(); @@ -178,7 +180,7 @@ function forgeDocRefreshCanvas() { forgeDocApplySelectionClass(); } else { window.forgeDocState.selectedId = null; - forgeDocRenderProps(null); + forgeDocRefreshRightPanelDefault(); } }).then(() => forgeDocCheckPageOverflow()); } @@ -518,6 +520,14 @@ function forgeDocApiPageMove(pageId, newIndex) { }).then((r) => r.json()); } +function forgeDocApiPageVerticalAlign(pageId, verticalAlign) { + return fetch(`/document/${window.forgeDocState.slug}/pages/${pageId}/vertical-align`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ vertical_align: verticalAlign }), + }).then((r) => r.json()); +} + function forgeDocOrderedPages() { return [...window.forgeDocState.pages].sort((a, b) => a.order_index - b.order_index); } @@ -542,7 +552,7 @@ function forgeDocSwitchPage(pageId) { window.forgeDocState.redoStack = []; forgeDocUpdateHistoryButtons(); window.forgeDocState.selectedId = null; - forgeDocRenderProps(null); + forgeDocRefreshRightPanelDefault(); forgeDocRenderPageManagerList(); forgeDocUpdatePageNavPill(); return forgeDocRefreshCanvas(); @@ -565,6 +575,18 @@ function forgeDocRenamePage(pageId, title) { }); } +function forgeDocSetPageVerticalAlign(pageId, verticalAlign) { + return forgeDocApiPageVerticalAlign(pageId, verticalAlign).then((result) => { + const page = window.forgeDocState.pages.find((p) => p.id === pageId); + if (page) page.vertical_align = result.vertical_align; + if (pageId === window.forgeDocState.activePageId) { + const contentEl = document.getElementById('docPageContent'); + if (contentEl) contentEl.dataset.verticalAlign = result.vertical_align; + } + forgeDocRenderPageProps(); + }); +} + function forgeDocDeletePage(pageId) { // Filet de sécurité client (en plus du refus serveur, voir // routes/document/document_page_delete.py) : jamais même tenter de @@ -758,6 +780,16 @@ function forgeDocSwitchSidebarTab(tabName) { tabs[name].setAttribute('aria-selected', String(isActive)); panels[name].hidden = !isActive; }); + // Le panneau Propriétés (droite) suit l'onglet actif (retour + // utilisateur du 24/09/2026) : la page active sur "Pages", l'élément + // sélectionné (ou rien) sur "Mise en page" — jamais les deux en même + // temps. + if (tabName === 'pages') { + forgeDocRenderPageProps(); + } else { + const { selectedId } = window.forgeDocState; + forgeDocRenderProps(selectedId !== null ? window.forgeDocState.elementsById[selectedId] : null); + } } function forgeDocBindSidebarTabs() { @@ -1608,6 +1640,53 @@ function forgeDocDeleteButtonHtml() { return ''; } +/* --------------------------------------------------------------------- + * Propriétés de PAGE (alignement vertical du contenu) — affichées dans + * le panneau de droite quand l'onglet "Pages" du panneau de gauche est + * actif (retour utilisateur du 24/09/2026), à la place des propriétés + * d'un élément sélectionné. Jamais les deux en même temps : changer + * d'onglet ou sélectionner un élément bascule l'un ou l'autre (voir + * forgeDocSwitchSidebarTab/forgeDocRefreshRightPanelDefault). + * ------------------------------------------------------------------- */ + +function forgeDocIsPagesTabActive() { + const tab = document.getElementById('docTabPages'); + return !!tab && tab.classList.contains('is-active'); +} + +function forgeDocRenderPageProps() { + const panel = document.getElementById('docPropsPanel'); + if (!panel) return; + const page = window.forgeDocState.pages.find((p) => p.id === window.forgeDocState.activePageId); + if (!page) { + panel.innerHTML = '
Propriétés
Aucune page active.
'; + return; + } + const align = page.vertical_align || 'top'; + const options = [ + { value: 'top', label: 'Haut' }, + { value: 'center', label: 'Centré' }, + { value: 'bottom', label: 'Bas' }, + ]; + panel.innerHTML = ` + ${forgeDocPropsHeader('page')} +
+ Alignement vertical du contenu +
+ ${options.map((o) => ``).join('')} +
+
+ `; + panel.querySelectorAll('#docPageAlignSeg .docSegBtn').forEach((btn) => { + btn.addEventListener('click', () => forgeDocSetPageVerticalAlign(page.id, btn.dataset.align)); + }); +} + +function forgeDocRefreshRightPanelDefault() { + if (forgeDocIsPagesTabActive()) forgeDocRenderPageProps(); + else forgeDocRenderProps(null); +} + function forgeDocRenderTextProps(panel, el) { const a = el.attributes; panel.innerHTML = ` @@ -3091,7 +3170,7 @@ function forgeDocBindDeselectOnBackgroundClick() { if (e.target.closest('[data-element-id]')) return; window.forgeDocState.selectedId = null; forgeDocApplySelectionClass(); - forgeDocRenderProps(null); + forgeDocRefreshRightPanelDefault(); }); } diff --git a/templates/document/document_edit.html b/templates/document/document_edit.html index c824cf18..b1acae8c 100644 --- a/templates/document/document_edit.html +++ b/templates/document/document_edit.html @@ -205,7 +205,9 @@
-
{{ rendered_document|safe }}
+
{{ rendered_document|safe }}
{# //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 #}
diff --git a/tests/document/test_document_elements.py b/tests/document/test_document_elements.py index 368fcb9a..d169a79a 100644 --- a/tests/document/test_document_elements.py +++ b/tests/document/test_document_elements.py @@ -335,24 +335,29 @@ def test_replace_document_content_rebuilds_pages_and_elements_from_seed(tmp_supp slug, page_id = _new_support(tmp_support_slug_cleanup) doc_engine.add_document_element(slug, "titre", page_id=page_id) - seed_pages: list[list[dict[str, Any]]] = [ - [ - {"kind": "titre", "attributes": {"content": "Page 1 du modèle"}}, - { - "kind": "row", - "attributes": {"gap": 20}, - "children": [ - {"kind": "paragraphe", "attributes": {"content": "Colonne gauche"}}, - {"kind": "paragraphe", "attributes": {"content": "Colonne droite"}}, - ], - }, - ], - [{"kind": "liste_puces", "attributes": {"items": ["Un", "Deux"]}}], + seed_pages: list[dict[str, Any]] = [ + { + "vertical_align": "center", + "blocks": [ + {"kind": "titre", "attributes": {"content": "Page 1 du modèle"}}, + { + "kind": "row", + "attributes": {"gap": 20}, + "children": [ + {"kind": "paragraphe", "attributes": {"content": "Colonne gauche"}}, + {"kind": "paragraphe", "attributes": {"content": "Colonne droite"}}, + ], + }, + ], + }, + {"blocks": [{"kind": "liste_puces", "attributes": {"items": ["Un", "Deux"]}}]}, ] doc_engine.replace_document_content(slug, seed_pages) pages = doc_engine.list_document_pages(slug) assert len(pages) == 2 + assert pages[0]["vertical_align"] == "center" + assert pages[1]["vertical_align"] == "top" page1_elements = doc_engine.list_document_elements(slug, pages[0]["id"]) titre = next(el for el in page1_elements if el["kind"] == "titre") @@ -369,7 +374,9 @@ def test_replace_document_content_rebuilds_pages_and_elements_from_seed(tmp_supp def test_replace_document_content_merges_attributes_onto_defaults(tmp_support_slug_cleanup: Any) -> None: slug, _page_id = _new_support(tmp_support_slug_cleanup) - doc_engine.replace_document_content(slug, [[{"kind": "titre", "attributes": {"content": "Seulement le contenu"}}]]) + doc_engine.replace_document_content( + slug, [{"blocks": [{"kind": "titre", "attributes": {"content": "Seulement le contenu"}}]}] + ) page_id = doc_engine.list_document_pages(slug)[0]["id"] element = doc_engine.list_document_elements(slug, page_id)[0] assert element["attributes"]["content"] == "Seulement le contenu" diff --git a/tests/document/test_document_pages_routes.py b/tests/document/test_document_pages_routes.py index 1e88ca86..7115f0d1 100644 --- a/tests/document/test_document_pages_routes.py +++ b/tests/document/test_document_pages_routes.py @@ -93,3 +93,33 @@ def test_document_page_move_reorders_the_tab_strip(client: FlaskClient, support: def test_document_page_move_rejects_an_unknown_page(client: FlaskClient, support: str) -> None: assert client.post(f"/document/{support}/pages/999/move", json={"new_index": 0}).status_code == 404 + + +def test_new_page_defaults_to_top_vertical_align(client: FlaskClient, support: str) -> None: + page_id = _page_id(support) + page = document_engine.get_document_page(support, page_id) + assert page is not None + assert page["vertical_align"] == "top" + + +def test_document_page_vertical_align_persists_a_valid_value(client: FlaskClient, support: str) -> None: + page_id = _page_id(support) + resp = client.post(f"/document/{support}/pages/{page_id}/vertical-align", json={"vertical_align": "center"}) + assert resp.status_code == 200 + assert resp.get_json() == {"ok": True, "vertical_align": "center"} + page = document_engine.get_document_page(support, page_id) + assert page is not None + assert page["vertical_align"] == "center" + + +def test_document_page_vertical_align_falls_back_to_top_for_an_invalid_value(client: FlaskClient, support: str) -> None: + page_id = _page_id(support) + client.post(f"/document/{support}/pages/{page_id}/vertical-align", json={"vertical_align": "center"}) + resp = client.post(f"/document/{support}/pages/{page_id}/vertical-align", json={"vertical_align": "n-importe-quoi"}) + assert resp.status_code == 200 + assert resp.get_json()["vertical_align"] == "top" + + +def test_document_page_vertical_align_rejects_an_unknown_page(client: FlaskClient, support: str) -> None: + resp = client.post(f"/document/{support}/pages/999/vertical-align", json={"vertical_align": "center"}) + assert resp.status_code == 404 diff --git a/tests/document/test_document_routes.py b/tests/document/test_document_routes.py index c5f5ea0e..4d57fac1 100644 --- a/tests/document/test_document_routes.py +++ b/tests/document/test_document_routes.py @@ -224,6 +224,21 @@ def test_document_render_rejects_a_missing_or_unknown_page(client: FlaskClient, assert client.get(f"/document/{support}/render", query_string={"page_id": 999}).status_code == 404 +def test_document_render_includes_the_page_vertical_align(client: FlaskClient, support: str) -> None: + page_id = _page_id(support) + client.post(f"/document/{support}/pages/{page_id}/vertical-align", json={"vertical_align": "center"}) + resp = client.get(f"/document/{support}/render", query_string={"page_id": page_id}) + assert resp.status_code == 200 + assert resp.get_json()["vertical_align"] == "center" + + +def test_document_edit_reflects_the_active_page_vertical_align(client: FlaskClient, support: str) -> None: + page_id = _page_id(support) + client.post(f"/document/{support}/pages/{page_id}/vertical-align", json={"vertical_align": "center"}) + resp = client.get(f"/document/{support}/edit") + assert 'data-vertical-align="center"' in resp.get_data(as_text=True) + + def test_document_element_add_rejects_a_minigame_on_a_non_empty_page(client: FlaskClient, support: str) -> None: """Retour utilisateur du 23/09/2026 : "un mini jeu dois occupper toute une page" -> "une page avec mini-jeu = uniquement ce diff --git a/tests/document/test_document_themes.py b/tests/document/test_document_themes.py index 53345b06..6eab3de4 100644 --- a/tests/document/test_document_themes.py +++ b/tests/document/test_document_themes.py @@ -22,6 +22,16 @@ def test_get_document_theme_entry_returns_the_matching_entry() -> None: assert len(entry["seed_pages"]) > 0 +def test_securite_incendie_title_page_is_vertically_centered() -> None: + """Retour utilisateur du 24/09/2026 : la page de garde doit être + centrée verticalement, comme une page de titre — les pages suivantes + restent alignées en haut (comportement par défaut).""" + entry = doc_engine.get_document_theme_entry("securite-incendie") + assert entry is not None + assert entry["seed_pages"][0]["vertical_align"] == "center" + assert entry["seed_pages"][1].get("vertical_align", "top") == "top" + + def test_seed_blocks_to_elements_assigns_unique_synthetic_ids() -> None: blocks: list[dict[str, Any]] = [ {"kind": "titre", "attributes": {"content": "Titre"}}, @@ -53,11 +63,18 @@ def test_seed_blocks_to_elements_merges_onto_default_attributes() -> None: def test_seed_blocks_to_elements_renders_without_error() -> None: theme = doc_engine.get_document_theme_entry("securite-incendie") assert theme is not None - elements = doc_engine.seed_blocks_to_elements(theme["seed_pages"][0]) + elements = doc_engine.seed_blocks_to_elements(theme["seed_pages"][0]["blocks"]) html = doc_engine.render_document(elements) assert "docText" in html +def test_every_theme_seed_page_has_a_valid_vertical_align() -> None: + for theme in doc_engine.DOCUMENT_THEMES: + for seed_page in theme["seed_pages"]: + align = seed_page.get("vertical_align", "top") + assert align in doc_engine.VERTICAL_ALIGNS, f"thème {theme['id']!r} : vertical_align invalide {align!r}" + + def test_every_theme_seed_page_respects_the_minigame_exclusivity_rule() -> None: """Un mini-jeu doit toujours être SEUL sur sa page (règle appliquée côté route pour un ajout manuel, voir routes/document/ @@ -66,7 +83,8 @@ def test_every_theme_seed_page_respects_the_minigame_exclusivity_rule() -> None: ne la revérifie pas elle-même (contenu fourni par le thème, pas par l'utilisateur).""" for theme in doc_engine.DOCUMENT_THEMES: - for page_blocks in theme["seed_pages"]: + for seed_page in theme["seed_pages"]: + page_blocks = seed_page["blocks"] minigame_blocks = [b for b in page_blocks if b["kind"] in doc_engine.MINIGAME_KINDS] if minigame_blocks: assert len(page_blocks) == 1, (