- 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.
747 lines
35 KiB
Python
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)")
|