DOCS/outputs/mnaha/build_decks.py
Thomas Fransolet d14f151517 Livrables de presentation, seed de demo MNAHA et wireframes
- outputs/platform, outputs/prospect : scripts de construction des decks
  commerciaux + captures sources et .pptx generes.
- outputs/mnaha : seed complet de l'instance de demo luxembourgeoise
  (scripts par etape, contenus, images generees), le deck en version
  marque blanche et en version brandee, le PDF de presentation.
- outputs/Wireframes : maquettes app mobile, app web et manager.
- interview clients : notes de l'entretien Fourneau St Michel.

state.json et les __pycache__ sont ignores : le premier porte les cles
d'API de l'instance seedee, les scripts le regenerent.
2026-09-11 15:47:57 +02:00

747 lines
35 KiB
Python

# -*- coding: utf-8 -*-
"""Génère les deux decks MNAHA (brandé + marque blanche) depuis un contenu unique.
Chaque slide visuelle porte un emplacement de capture nommé, à remplacer plus tard
par la capture réelle sans toucher à la structure.
"""
import copy
from pathlib import Path
from pptx import Presentation
from pptx.dml.color import RGBColor
from pptx.enum.shapes import MSO_SHAPE
from pptx.enum.text import MSO_ANCHOR, PP_ALIGN
from pptx.util import Emu, Inches, Pt
# Le luxembourgeois n'est pas dans la liste des langues gérées par le code
# (FR, NL, EN, DE, IT, ES, PL, CN, AR, UK). Passer à True une fois "LB" ajouté
# aux constantes de manager-app et des applications visiteur.
LUXEMBOURGEOIS_ACTIF = False
ROOT = Path(__file__).resolve().parent
LOGO = ROOT.parent.parent / "myinfomate-landing" / "public" / "myinfomate-logo.png"
W = Inches(13.333)
H = Inches(7.5)
MARGIN = Inches(0.85)
class Theme:
def __init__(self, branded):
self.branded = branded
if branded:
self.ink = RGBColor(0x0A, 0x12, 0x22)
self.bg = RGBColor(0xF5, 0xF7, 0xFA)
self.surface = RGBColor(0xFF, 0xFF, 0xFF)
self.accent = RGBColor(0x0E, 0x8F, 0x8A)
self.accent_bright = RGBColor(0x0D, 0xF2, 0xDF)
self.muted = RGBColor(0x64, 0x74, 0x8B)
self.rule = RGBColor(0xD6, 0xDD, 0xE5)
self.title_font = "Segoe UI Semibold"
self.body_font = "Segoe UI"
else:
self.ink = RGBColor(0x1C, 0x1A, 0x17)
self.bg = RGBColor(0xFA, 0xF8, 0xF5)
self.surface = RGBColor(0xFF, 0xFF, 0xFF)
self.accent = RGBColor(0x8C, 0x6A, 0x3F)
self.accent_bright = RGBColor(0xB8, 0x93, 0x62)
self.muted = RGBColor(0x6B, 0x65, 0x58)
self.rule = RGBColor(0xE0, 0xDA, 0xD0)
self.title_font = "Georgia"
self.body_font = "Segoe UI"
def blank(prs):
return prs.slides.add_slide(prs.slide_layouts[6])
def rect(slide, x, y, w, h, fill=None, line=None, line_w=Pt(1), shape=MSO_SHAPE.RECTANGLE,
dash=False):
s = slide.shapes.add_shape(shape, x, y, w, h)
s.shadow.inherit = False
if fill is None:
s.fill.background()
else:
s.fill.solid()
s.fill.fore_color.rgb = fill
if line is None:
s.line.fill.background()
else:
s.line.color.rgb = line
s.line.width = line_w
if dash:
s.line.dash_style = 4 # MSO_LINE_DASH_STYLE.DASH
s.text_frame.clear()
return s
def text(slide, x, y, w, h, runs, align=PP_ALIGN.LEFT, anchor=MSO_ANCHOR.TOP,
line_spacing=1.15):
"""runs = [(texte, taille_pt, gras, couleur, police, espace_avant_pt), ...]"""
box = slide.shapes.add_textbox(x, y, w, h)
tf = box.text_frame
tf.word_wrap = True
tf.vertical_anchor = anchor
tf.margin_left = tf.margin_right = tf.margin_top = tf.margin_bottom = 0
for i, (content, size, bold, color, font, space_before) in enumerate(runs):
p = tf.paragraphs[0] if i == 0 else tf.add_paragraph()
p.alignment = align
p.line_spacing = line_spacing
if space_before:
p.space_before = Pt(space_before)
r = p.add_run()
r.text = content
r.font.size = Pt(size)
r.font.bold = bold
r.font.color.rgb = color
r.font.name = font
return box
def page_base(prs, th, dark=False):
s = blank(prs)
rect(s, 0, 0, W, H, fill=th.ink if dark else th.bg)
return s
def footer(slide, th, number, label=None):
color = th.muted
if label:
text(slide, MARGIN, H - Inches(0.62), Inches(8), Inches(0.3),
[(label, 9, False, color, th.body_font, 0)])
text(slide, W - MARGIN - Inches(1.2), H - Inches(0.62), Inches(1.2), Inches(0.3),
[(str(number), 9, False, color, th.body_font, 0)], align=PP_ALIGN.RIGHT)
"""Grille verticale fixe — le titre tient sur deux lignes au maximum, donc rien
ne se déplace d'une slide à l'autre et rien ne peut se chevaucher."""
Y_KICKER = Inches(0.95)
Y_TITLE = Inches(1.38)
Y_RULE = Inches(2.78)
Y_SUB = Inches(3.05)
Y_BODY = Inches(3.62)
Y_BODY_NO_SUB = Inches(3.10)
def kicker_title(slide, th, kicker, title, subtitle=None, width=None):
width = width or (W - 2 * MARGIN)
if kicker:
text(slide, MARGIN, Y_KICKER, width, Inches(0.3),
[(kicker.upper(), 10.5, True, th.accent, th.body_font, 0)])
text(slide, MARGIN, Y_TITLE, width, Inches(1.3),
[(title, 30, True, th.ink, th.title_font, 0)], line_spacing=1.06)
rect(slide, MARGIN, Y_RULE, Inches(0.9), Pt(2.5), fill=th.accent)
if subtitle:
text(slide, MARGIN, Y_SUB, min(width, Inches(9.2)), Inches(0.85),
[(subtitle, 13.5, False, th.muted, th.body_font, 0)], line_spacing=1.3)
return Y_BODY
return Y_BODY_NO_SUB
def placeholder(slide, th, x, y, w, h, ref, label, hint=None):
rect(slide, x, y, w, h, fill=th.surface, line=th.rule, line_w=Pt(1))
rect(slide, x + Inches(0.12), y + Inches(0.12), w - Inches(0.24), h - Inches(0.24),
fill=None, line=th.accent_bright, line_w=Pt(1), dash=True)
runs = [(ref, 11, True, th.accent, th.body_font, 0),
(label, 13, False, th.ink, th.body_font, 8)]
if hint:
runs.append((hint, 10, False, th.muted, th.body_font, 6))
text(slide, x + Inches(0.5), y, w - Inches(1.0), h, runs,
align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE, line_spacing=1.25)
def bullets(slide, th, x, y, w, items, size=13, gap=Inches(0.17)):
"""Hauteur estimée à partir d'une largeur de glyphe volontairement pessimiste :
mieux vaut un blanc en bas qu'un chevauchement."""
chars_per_line = max(20, int((w - Inches(0.34)) / (Inches(0.0098) * size)))
cur = y
for item in items:
rect(slide, x, cur + Inches(0.075), Inches(0.12), Inches(0.12),
fill=th.accent, shape=MSO_SHAPE.OVAL)
lines = -(-len(item) // chars_per_line)
h = Inches(0.245) * lines
text(slide, x + Inches(0.34), cur - Inches(0.03), w - Inches(0.34), h + Inches(0.1),
[(item, size, False, th.ink, th.body_font, 0)], line_spacing=1.3)
cur += h + gap
return cur
# --------------------------------------------------------------------------- slides
def slide_cover(prs, th, n):
s = page_base(prs, th, dark=True)
rect(s, 0, H - Inches(0.9), W, Inches(0.9), fill=th.accent)
text(s, MARGIN, Inches(2.15), Inches(10.4), Inches(0.4),
[("MUSÉE NATIONAL D'ARCHÉOLOGIE, D'HISTOIRE ET D'ART — LUXEMBOURG", 11, True,
th.accent_bright, th.body_font, 0)])
text(s, MARGIN, Inches(2.75), Inches(10.6), Inches(1.8),
[("Piloter les contenus\nde vos trois sites", 44, True,
RGBColor(0xFF, 0xFF, 0xFF), th.title_font, 0)], line_spacing=1.05)
text(s, MARGIN, Inches(4.75), Inches(9.6), Inches(1.0),
[("Un système unique de gestion pour les bornes, l'application mobile "
"et le web — sans intervention technique.", 15, False,
RGBColor(0xB8, 0xC2, 0xCE), th.body_font, 0)], line_spacing=1.35)
if th.branded and LOGO.exists():
s.shapes.add_picture(str(LOGO), W - MARGIN - Inches(1.25), Inches(0.75),
height=Inches(1.05))
return s
def slide_constat(prs, th, n):
s = page_base(prs, th)
y = kicker_title(s, th, "Le constat",
"Les dispositifs sont en place. Les contenus, eux, ne bougent plus.")
cards = [
("Chaque modification passe par un tiers",
"Corriger une date, remplacer une photo, ajouter une langue : autant de demandes "
"à formuler, à budgéter, à attendre."),
("Le délai décourage la mise à jour",
"Quand publier prend des semaines, on renonce. Le contenu vieillit sur des écrans "
"qui, eux, fonctionnent parfaitement."),
("Trois sites, trois chaînes séparées",
"Fëschmaart, Dräi Eechelen, Echternach : rien n'est mutualisé, et rien ne se "
"mesure d'un site à l'autre."),
]
x = MARGIN
cw = (W - 2 * MARGIN - Inches(0.6)) / 3
for title, body in cards:
rect(s, x, y, cw, Inches(2.9), fill=th.surface, line=th.rule)
rect(s, x, y, cw, Pt(3), fill=th.accent)
text(s, x + Inches(0.4), y + Inches(0.42), cw - Inches(0.8), Inches(0.8),
[(title, 15, True, th.ink, th.title_font, 0)], line_spacing=1.2)
text(s, x + Inches(0.4), y + Inches(1.35), cw - Inches(0.8), Inches(1.4),
[(body, 12, False, th.muted, th.body_font, 0)], line_spacing=1.35)
x += cw + Inches(0.3)
footer(s, th, n, "Le constat")
return s
def slide_proposition(prs, th, n):
s = page_base(prs, th)
y = kicker_title(s, th, "La proposition",
"Un seul back-office alimente tous vos dispositifs.",
"Vos équipes saisissent le contenu une fois. Il apparaît là où vous "
"décidez qu'il apparaisse — sur les bornes des salles, dans "
"l'application des visiteurs, sur le web.")
x = MARGIN
cw = (W - 2 * MARGIN - Inches(0.6)) / 3
for head, sub in [
("Les bornes en salle", "Écrans et bornes déjà installés au Fëschmaart et à "
"Dräi Eechelen, pilotés à distance."),
("L'application mobile", "Les trois sites réunis dans une seule application, "
"sur le téléphone du visiteur."),
("Le web", "Le même contenu accessible depuis un navigateur, sans installation."),
]:
rect(s, x, y, cw, Inches(2.15), fill=th.surface, line=th.rule)
text(s, x + Inches(0.4), y + Inches(0.38), cw - Inches(0.8), Inches(0.4),
[(head, 16, True, th.ink, th.title_font, 0)])
text(s, x + Inches(0.4), y + Inches(0.92), cw - Inches(0.8), Inches(1.0),
[(sub, 12, False, th.muted, th.body_font, 0)], line_spacing=1.35)
x += cw + Inches(0.3)
footer(s, th, n, "La proposition")
return s
def slide_architecture(prs, th, n):
s = page_base(prs, th)
kicker_title(s, th, "Comment cela fonctionne",
"Un contenu saisi une fois, diffusé partout, mesuré en retour.")
cy = Inches(3.9)
bw, bh = Inches(2.55), Inches(1.35)
left_x = MARGIN + Inches(0.15)
rect(s, left_x, cy - bh / 2, bw, bh, fill=th.ink)
text(s, left_x + Inches(0.25), cy - bh / 2, bw - Inches(0.5), bh,
[("Le back-office", 15, True, RGBColor(0xFF, 0xFF, 0xFF), th.title_font, 0),
("Vos équipes\nde médiation", 11, False, RGBColor(0xC5, 0xCE, 0xD8),
th.body_font, 6)],
align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE, line_spacing=1.2)
hub_x = left_x + bw + Inches(1.15)
hub_w = Inches(2.35)
rect(s, hub_x, cy - bh / 2, hub_w, bh, fill=th.surface, line=th.accent, line_w=Pt(1.5))
text(s, hub_x + Inches(0.2), cy - bh / 2, hub_w - Inches(0.4), bh,
[("Contenus, médias\net langues", 13.5, True, th.ink, th.title_font, 0),
("Une source unique", 11, False, th.muted, th.body_font, 6)],
align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE, line_spacing=1.2)
rect(s, left_x + bw, cy - Pt(1), Inches(1.15), Pt(2), fill=th.accent)
out_x = hub_x + hub_w + Inches(1.15)
rect(s, hub_x + hub_w, cy - Pt(1), Inches(1.15), Pt(2), fill=th.accent)
ow = Inches(2.75)
oh = Inches(0.92)
tops = [cy - Inches(1.55), cy - Inches(0.46), cy + Inches(0.63)]
for top, (name, sub) in zip(tops, [
("Bornes en salle", "Fëschmaart · Dräi Eechelen"),
("Application mobile", "Les trois sites"),
("Web", "Sans installation"),
]):
rect(s, out_x, top, ow, oh, fill=th.surface, line=th.rule)
text(s, out_x + Inches(0.22), top, ow - Inches(0.44), oh,
[(name, 13, True, th.ink, th.title_font, 0),
(sub, 10, False, th.muted, th.body_font, 4)],
align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE, line_spacing=1.15)
ret_y = cy + Inches(2.05)
rect(s, left_x, ret_y, out_x + ow - left_x, Pt(2), fill=th.rule)
rect(s, left_x, cy + bh / 2, Pt(2), ret_y - cy - bh / 2, fill=th.rule)
rect(s, out_x + ow - Pt(2), tops[2] + oh, Pt(2), ret_y - tops[2] - oh, fill=th.rule)
arrow = s.shapes.add_shape(MSO_SHAPE.ISOSCELES_TRIANGLE, left_x - Inches(0.02),
ret_y - Inches(0.09), Inches(0.2), Inches(0.2))
arrow.rotation = 270
arrow.shadow.inherit = False
arrow.fill.solid(); arrow.fill.fore_color.rgb = th.rule
arrow.line.fill.background()
chip_w = Inches(5.0)
chip_x = left_x + ((out_x + ow - left_x) - chip_w) / 2
rect(s, chip_x, ret_y - Inches(0.16), chip_w, Inches(0.34), fill=th.bg)
text(s, chip_x, ret_y - Inches(0.11), chip_w, Inches(0.3),
[("Statistiques de fréquentation et questions des visiteurs", 11.5, False,
th.muted, th.body_font, 0)], align=PP_ALIGN.CENTER)
footer(s, th, n, "Architecture fonctionnelle")
return s
def slide_split(prs, th, n, kicker, title, subtitle, items, ref, cap_label, cap_hint=None,
footer_label=None, portrait=False):
s = page_base(prs, th)
kicker_title(s, th, kicker, title, subtitle, width=Inches(6.05))
bullets(s, th, MARGIN, Inches(3.98), Inches(5.75), items)
if portrait:
placeholder(s, th, Inches(8.35), Inches(1.15), Inches(2.85), Inches(5.5),
ref, cap_label, cap_hint)
else:
placeholder(s, th, Inches(7.35), Inches(1.75), Inches(5.1), Inches(3.9),
ref, cap_label, cap_hint)
footer(s, th, n, footer_label or kicker)
return s
def slide_grid(prs, th, n):
s = page_base(prs, th)
kicker_title(s, th, "Types de contenus",
"Quatorze formats, combinables librement dans chaque espace.",
"Un espace se compose comme un sommaire : on y ajoute les formats voulus, "
"on les ordonne, on les traduit.")
tiles = [
("Article", "Texte enrichi, images, audio"),
("Carte", "Points d'intérêt, zones, catégories"),
("Diaporama", "Séries d'images légendées"),
("Vidéo", "Fichier ou lien en ligne"),
("Document PDF", "Consultable dans l'app"),
("Agenda", "Événements datés, filtrables"),
("Sommaire", "Menu thématique de sous-espaces"),
("Quiz", "Questions, réponses, score"),
("Jeu", "Puzzle, taquin"),
("Parcours", "Étapes guidées, escape game"),
("Événement", "Programme et plan dédiés"),
("Page web", "Site externe intégré"),
("Météo", "Conditions du jour"),
("Compte à rebours", "Mise en avant d'un événement"),
]
cols, rows = 5, 3
gx, gy = Inches(0.2), Inches(0.16)
tw = (W - 2 * MARGIN - gx * (cols - 1)) / cols
thh = Inches(0.9)
x0, y0 = MARGIN, Inches(3.68)
for i, (name, sub) in enumerate(tiles):
c, r = i % cols, i // cols
if r >= rows:
break
x = x0 + c * (tw + gx)
y = y0 + r * (thh + gy)
rect(s, x, y, tw, thh, fill=th.surface, line=th.rule)
rect(s, x, y, Pt(3), thh, fill=th.accent)
text(s, x + Inches(0.22), y + Inches(0.14), tw - Inches(0.35), Inches(0.3),
[(name, 12.5, True, th.ink, th.title_font, 0)])
text(s, x + Inches(0.22), y + Inches(0.47), tw - Inches(0.35), Inches(0.38),
[(sub, 9.5, False, th.muted, th.body_font, 0)], line_spacing=1.15)
footer(s, th, n, "Types de contenus")
return s
def slide_stats(prs, th, n):
s = page_base(prs, th)
y = kicker_title(s, th, "Fréquentation",
"Ce que les visiteurs consultent vraiment.",
width=Inches(6.05))
kpis = [("Visites", "par jour et par période"),
("Contenus", "les plus et les moins consultés"),
("Points d'intérêt", "salles et œuvres les plus visitées"),
("Quiz et parcours", "taux d'achèvement, scores")]
x = MARGIN
kw = (Inches(6.0) - Inches(0.25)) / 2
for i, (t, sub) in enumerate(kpis):
cx = x + (i % 2) * (kw + Inches(0.25))
cy = y + (i // 2) * Inches(1.15)
rect(s, cx, cy, kw, Inches(0.95), fill=th.surface, line=th.rule)
text(s, cx + Inches(0.28), cy + Inches(0.16), kw - Inches(0.5), Inches(0.3),
[(t, 13, True, th.ink, th.title_font, 0)])
text(s, cx + Inches(0.28), cy + Inches(0.5), kw - Inches(0.5), Inches(0.3),
[(sub, 10, False, th.muted, th.body_font, 0)])
text(s, MARGIN, y + Inches(2.45), Inches(6.0), Inches(0.9),
[("Filtrable par période et par dispositif — borne, mobile, web. "
"Exportable en rapport PDF pour un comité ou une tutelle.",
12, False, th.muted, th.body_font, 0)], line_spacing=1.35)
placeholder(s, th, Inches(7.35), Inches(1.75), Inches(5.1), Inches(3.9),
"CAPTURE 12", "Tableau de bord des statistiques",
"KPI, courbe des visites par jour, contenus les plus consultés")
footer(s, th, n, "Fréquentation")
return s
def slide_references(prs, th, n):
s = page_base(prs, th)
if th.branded:
kicker_title(s, th, "Références",
"Trois déploiements en production, depuis 2021.")
cases = [
("Musée de la Fraise, Wépion", "2021 — borne tactile en salle"),
("Fort de Saint-Héribert", "2023 — application mobile intégralement "
"hors-ligne, balises BLE"),
("Visit Namur", "2024 — borne tactile grand format en office de tourisme"),
]
else:
kicker_title(s, th, "Références",
"La solution est déployée sur trois sites en Belgique, depuis 2021.")
cases = [
("Musée thématique", "2021 — borne tactile en salle"),
("Site fortifié", "2023 — application mobile intégralement hors-ligne, "
"balises BLE"),
("Office de tourisme", "2024 — borne tactile grand format"),
]
x = MARGIN
cw = (W - 2 * MARGIN - Inches(0.6)) / 3
for i, (name, sub) in enumerate(cases):
rect(s, x, Inches(3.10), cw, Inches(3.2), fill=th.surface, line=th.rule)
placeholder(s, th, x + Inches(0.25), Inches(3.35), cw - Inches(0.5), Inches(1.75),
f"CAPTURE {13 + i}",
name if th.branded else f"Déploiement {i + 1}")
text(s, x + Inches(0.3), Inches(5.35), cw - Inches(0.6), Inches(0.35),
[(name, 13.5, True, th.ink, th.title_font, 0)])
text(s, x + Inches(0.3), Inches(5.74), cw - Inches(0.6), Inches(0.45),
[(sub, 10.5, False, th.muted, th.body_font, 0)], line_spacing=1.2)
x += cw + Inches(0.3)
footer(s, th, n, "Références")
return s
def slide_phases(prs, th, n):
s = page_base(prs, th)
kicker_title(s, th, "Mise en œuvre",
"Par étapes, en commençant par un périmètre maîtrisable.")
phases = [
("Phase 1", "Pilote au Fëschmaart",
"Reprise des contenus d'une aile, mise en service sur les bornes existantes, "
"formation de l'équipe de médiation."),
("Phase 2", "Application mobile des trois sites",
"Les trois sites réunis dans une application unique : contenus, cartes, "
"audioguide, assistant."),
("Phase 3", "Echternach et parcours hors-ligne",
"Parcours extérieur, balises, téléchargement complet pour les zones sans réseau."),
]
x = MARGIN
cw = (W - 2 * MARGIN - Inches(0.7)) / 3
for i, (num, title, body) in enumerate(phases):
rect(s, x, Inches(3.10), cw, Inches(3.0), fill=th.surface, line=th.rule)
rect(s, x, Inches(3.10), cw, Pt(3), fill=th.accent)
text(s, x + Inches(0.35), Inches(3.45), cw - Inches(0.7), Inches(0.3),
[(num.upper(), 10.5, True, th.accent, th.body_font, 0)])
text(s, x + Inches(0.35), Inches(3.85), cw - Inches(0.7), Inches(0.8),
[(title, 15, True, th.ink, th.title_font, 0)], line_spacing=1.15)
text(s, x + Inches(0.35), Inches(4.80), cw - Inches(0.7), Inches(1.1),
[(body, 11.5, False, th.muted, th.body_font, 0)], line_spacing=1.35)
x += cw + Inches(0.35)
footer(s, th, n, "Mise en œuvre")
return s
def slide_next(prs, th, n):
s = page_base(prs, th, dark=True)
text(s, MARGIN, Inches(1.35), Inches(10), Inches(0.35),
[("PROCHAINES ÉTAPES", 11, True, th.accent_bright, th.body_font, 0)])
text(s, MARGIN, Inches(1.95), Inches(9.5), Inches(1.0),
[("Ce que nous proposons\npour avancer", 34, True, RGBColor(0xFF, 0xFF, 0xFF),
th.title_font, 0)], line_spacing=1.08)
steps = [
"Une démonstration du back-office sur vos propres contenus — une salle du "
"Fëschmaart suffit.",
"Un atelier avec l'équipe de médiation pour cadrer les formats et les langues.",
"Un périmètre de pilote chiffré et daté, sans engagement sur la suite.",
]
y = Inches(4.0)
for i, st in enumerate(steps, 1):
rect(s, MARGIN, y, Inches(0.42), Inches(0.42), fill=th.accent,
shape=MSO_SHAPE.OVAL)
text(s, MARGIN, y + Inches(0.06), Inches(0.42), Inches(0.3),
[(str(i), 12, True, RGBColor(0xFF, 0xFF, 0xFF), th.body_font, 0)],
align=PP_ALIGN.CENTER)
text(s, MARGIN + Inches(0.72), y + Inches(0.02), Inches(9.6), Inches(0.6),
[(st, 14, False, RGBColor(0xE2, 0xE8, 0xF0), th.body_font, 0)],
line_spacing=1.3)
y += Inches(0.92)
return s
def build(branded, out_path):
th = Theme(branded)
prs = Presentation()
prs.slide_width = W
prs.slide_height = H
n = 1
slide_cover(prs, th, n); n += 1
slide_constat(prs, th, n); n += 1
slide_proposition(prs, th, n); n += 1
slide_architecture(prs, th, n); n += 1
slide_split(prs, th, n, "Le back-office",
"Modifier un contenu prend une minute.",
"L'éditeur ressemble à un traitement de texte. Aucune compétence "
"technique, aucun prestataire à solliciter.",
["Texte enrichi, images, audio, vidéo et documents dans un même écran.",
"Publication immédiate : les bornes connectées se mettent à jour sans "
"manipulation sur place.",
"Chaque champ existe dans toutes les langues déclarées.",
"Journal des modifications : qui a changé quoi, et quand."],
"CAPTURE 01", "Éditeur de contenu",
"Fiche d'une œuvre avec ses onglets de langue", "Le back-office"); n += 1
slide_grid(prs, th, n); n += 1
slide_split(prs, th, n, "Les bornes",
"Vos bornes existantes, alimentées à distance.",
"Le matériel déjà installé au Fëschmaart et à Dräi Eechelen reste en "
"place. Seul le contenu change de main.",
["Choix de la langue dès le premier écran.",
"Navigation par thème : archéologie, cabinet des médailles, arts "
"décoratifs, beaux-arts.",
"Fiches d'œuvre avec image, texte et écoute audio.",
"Chaque borne peut afficher un contenu différent."],
"CAPTURE 02", "Accueil de la borne",
"Sélection de la langue, Fëschmaart", "Les bornes"); n += 1
slide_split(prs, th, n, "Application mobile",
"Les trois sites dans une seule application.",
"Le visiteur télécharge une application. Il y trouve le Fëschmaart, "
"Dräi Eechelen et la Réimervilla, et choisit où il se trouve.",
["Un espace par site, avec ses propres contenus et ses propres couleurs.",
"Cartes, audioguide, quiz, parcours : chaque site compose le sien.",
"Le visiteur passe d'un site à l'autre sans changer d'application.",
"Une même fréquentation mesurée sur les trois sites."],
"CAPTURE 03", "Accueil de l'application",
"Les trois sites en tuiles", "Application mobile", portrait=True); n += 1
slide_split(prs, th, n, "Identité visuelle",
"L'apparence de l'application se règle ici.",
"Couleurs, images, disposition : le musée compose sa page d'accueil "
"et voit le résultat en direct, sans nouvelle version de l'application.",
["Couleurs principales et secondaires, image d'accueil, écran de "
"chargement.",
"Tuiles redimensionnables : un site ou un événement peut occuper une "
"place plus large.",
"Aperçu en temps réel dans une maquette de téléphone.",
"L'application porte le nom et l'identité du musée, pas ceux d'un "
"éditeur."],
"CAPTURE 04", "Composition de l'accueil",
"Tuiles, couleurs et aperçu téléphone", "Identité visuelle"); n += 1
langue_titre = ("Luxembourgeois, français, allemand, anglais."
if LUXEMBOURGEOIS_ACTIF
else "Chaque contenu existe dans toutes vos langues.")
langue_puce = ("Onze langues gérées, dont le luxembourgeois, activables site par site."
if LUXEMBOURGEOIS_ACTIF
else "Dix langues gérées, activables site par site et dispositif par "
"dispositif.")
slide_split(prs, th, n, "Langues", langue_titre,
"Le gestionnaire saisit son texte une fois. Le visiteur choisit sa "
"langue au premier écran.",
[langue_puce,
"Traduction assistée : un bouton propose la version des autres langues "
"à partir du texte source.",
"Le gestionnaire relit et corrige avant publication — rien n'est publié "
"automatiquement.",
"L'assistant de visite répond dans la langue de la question."],
"CAPTURE 05", "Champ multilingue",
"Onglets de langue et traduction assistée", "Langues"); n += 1
slide_split(prs, th, n, "Assistant de visite",
"Un guide qui ne connaît que vos collections.",
"Le visiteur pose sa question en langage courant. La réponse est "
"construite à partir de vos contenus, et uniquement des vôtres.",
["Les réponses citent les contenus du musée — pas de source extérieure.",
"Quand l'information n'existe pas dans vos contenus, l'assistant le dit "
"plutôt que d'inventer.",
"Disponible sur la borne, dans l'application et sur le web.",
"Répond dans la langue du visiteur."],
"CAPTURE 06", "Conversation avec l'assistant",
"Deux langues différentes, côte à côte", "Assistant de visite"); n += 1
slide_split(prs, th, n, "Pilotage de l'assistant",
"Les questions sans réponse guident la médiation.",
"Chaque échange est journalisé sans donnée nominative. Les questions "
"restées sans réponse deviennent une liste de travail pour la médiation.",
["Liste des questions auxquelles l'assistant n'a rien pu répondre.",
"Réponses de repli rédigées par le musée pour les sujets sensibles.",
"Activation dispositif par dispositif, avec consommation suivie.",
"Consentement du visiteur et purge automatique des questions."],
"CAPTURE 07", "Écran de pilotage de l'assistant",
"Onglet des questions sans réponse", "Pilotage de l'assistant"); n += 1
slide_split(prs, th, n, "Codes QR",
"Du cartel au contenu, sans application préalable.",
"Le back-office produit les codes QR de vos salles et de vos œuvres, "
"dans un document prêt à imprimer.",
["Un code par contenu, généré et exporté en PDF depuis le back-office.",
"Le scan ouvre directement la fiche, dans la langue du visiteur.",
"Les scans sont comptés : vous savez quels cartels fonctionnent.",
"Un code reste valable même si le contenu derrière change."],
"CAPTURE 08", "Codes QR générés",
"Export PDF prêt à imprimer", "Codes QR"); n += 1
slide_split(prs, th, n, "Hors-ligne et balises",
"Fonctionne dans la roche comme en plein champ.",
"Le visiteur télécharge un site entier avant sa visite. Textes, images "
"et audio restent disponibles sans aucun réseau.",
["Téléchargement complet d'un site ou d'un parcours, en une action.",
"Salles creusées dans la roche : aucune connexion nécessaire.",
"Balises Bluetooth : le contenu de la salle s'affiche à l'approche.",
"Réimervilla : déclenchement par zone géographique en extérieur."],
"CAPTURE 09", "Téléchargement hors-ligne",
"Indicateur de contenu téléchargé", "Hors-ligne et balises",
portrait=True); n += 1
slide_split(prs, th, n, "Parcours et jeux",
"Du parcours thématique à la chasse au trésor.",
"Un même outil sert la visite guidée classique et le jeu de piste "
"familial ou scolaire.",
["Étapes ordonnées ou libres, visibles ou révélées au fur et à mesure.",
"Énigmes à résoudre pour débloquer l'étape suivante, minuteur optionnel.",
"Messages de début et de fin, écran de réussite, progression suivie "
"sur la carte.",
"Déclenchement d'une étape par balise, par zone géographique ou par "
"code QR."],
"CAPTURE 10", "Parcours en cours",
"Progression sur la carte et étape à résoudre", "Parcours et jeux"); n += 1
slide_split(prs, th, n, "Notifications",
"Prévenir vos visiteurs en une minute.",
"Les personnes ayant installé l'application reçoivent une notification, "
"envoyée depuis le back-office.",
["Envoi immédiat ou programmé à une date et une heure.",
"Rédaction dans chaque langue déclarée.",
"Historique des envois et annulation d'un envoi programmé.",
"Aucune donnée nominative n'est collectée."],
"CAPTURE 11", "Écran d'envoi de notification",
None, "Notifications"); n += 1
slide_stats(prs, th, n); n += 1
slide_split(prs, th, n, "Parc d'appareils",
"Savoir ce qu'affiche chaque borne.",
"Chaque appareil déclaré remonte son état. Vous changez le contenu "
"affiché depuis le back-office.",
["État de connexion, niveau de batterie, dernière activité, version "
"installée.",
"Attribution du contenu affiché appareil par appareil.",
"Un changement de contenu part vers l'appareil sans intervention "
"sur place.",
"Utile quand les bornes sont réparties sur trois sites distants."],
"CAPTURE 16", "Liste des appareils",
"État, batterie et contenu affiché", "Parc d'appareils"); n += 1
slide_split(prs, th, n, "Gouvernance",
"Chacun accède à ce qui le concerne.",
"Les droits se règlent par personne. Les modifications sont tracées.",
["Quatre niveaux : administration, gestion du site, rédaction, "
"consultation.",
"Journal d'audit des créations et modifications.",
"Hébergement européen, questions des visiteurs purgées "
"automatiquement.",
"Aucun compte visiteur, aucune donnée nominative collectée."],
"CAPTURE 17", "Gestion des utilisateurs",
None, "Gouvernance"); n += 1
slide_references(prs, th, n); n += 1
slide_phases(prs, th, n); n += 1
slide_next(prs, th, n)
scrub(prs, branded)
out_path.parent.mkdir(parents=True, exist_ok=True)
count = len(prs.slides._sldIdLst)
prs.save(str(out_path))
sanitize_package(out_path)
return out_path, count
APP_XML = (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/'
'extended-properties" xmlns:vt="http://schemas.openxmlformats.org/officeDocument/'
'2006/docPropsVTypes"><Application></Application><PresentationFormat>'
'Widescreen</PresentationFormat><ScaleCrop>false</ScaleCrop>'
'<LinksUpToDate>false</LinksUpToDate><SharedDoc>false</SharedDoc>'
'<HyperlinksChanged>false</HyperlinksChanged></Properties>'
)
def sanitize_package(path):
"""Le gabarit par défaut de python-pptx laisse traîner un producteur, un format
4:3 erroné, des dates de 2013 et une vignette d'aperçu. On réécrit l'archive."""
import re
import zipfile
src = zipfile.ZipFile(path)
items = [(i, src.read(i.filename)) for i in src.infolist()]
src.close()
stamp = "2026-08-24T09:00:00Z"
out = zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED)
for info, data in items:
name = info.filename
if name == "docProps/thumbnail.jpeg":
continue
if name == "docProps/app.xml":
data = APP_XML.encode("utf-8")
elif name == "docProps/core.xml":
txt = data.decode("utf-8")
txt = re.sub(r"(<dcterms:(?:created|modified)[^>]*>)[^<]*(</)",
lambda m: m.group(1) + stamp + m.group(2), txt)
data = txt.encode("utf-8")
elif name == "_rels/.rels":
txt = data.decode("utf-8")
txt = re.sub(r"<Relationship[^>]*thumbnail\.jpeg[^>]*/>", "", txt)
data = txt.encode("utf-8")
elif name == "[Content_Types].xml":
txt = data.decode("utf-8")
txt = txt.replace('<Default Extension="jpeg" ContentType="image/jpeg"/>', "")
data = txt.encode("utf-8")
out.writestr(info, data)
out.close()
def scrub(prs, branded):
cp = prs.core_properties
cp.author = "MyInfoMate" if branded else ""
cp.last_modified_by = "MyInfoMate" if branded else ""
cp.company = ""
cp.title = "MNAHA — Gestion de contenu multi-sites"
cp.subject = ""
cp.comments = ""
cp.category = ""
cp.keywords = ""
cp.content_status = ""
cp.identifier = ""
cp.language = "fr-BE"
cp.revision = 1
if __name__ == "__main__":
for branded, sub, name in [
(True, "branded", "MNAHA_presentation_branded.pptx"),
(False, "whitelabel", "MNAHA_presentation_whitelabel.pptx"),
]:
path, count = build(branded, ROOT / sub / name)
print(f"{path} ({count} slides)")