DOCS/outputs/prospect/build_prospect_deck.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

439 lines
20 KiB
Python

# -*- coding: utf-8 -*-
"""Deck de présentation MyInfoMate — audience prospect.
Même matière que le deck plateforme, mais retournée : celui-là défendait un
patrimoine devant un acquéreur (volume de code, historique, coût de
reconstruction), celui-ci défend un bénéfice devant un client. D'où trois règles
de rédaction tenues d'un bout à l'autre :
- aucun chiffre de production interne (lignes, commits, écrans, points d'API) ;
- aucun nom de dépôt ni de technologie dans le corps du propos ;
- chaque titre énonce ce que le client gagne, pas ce que le produit contient.
Les captures viennent du deck plateforme, extraites dans ../platform/captures.
"""
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent
sys.path.insert(0, str(ROOT.parent / "mnaha"))
from pptx import Presentation
from pptx.dml.color import RGBColor
from pptx.enum.text import MSO_ANCHOR, PP_ALIGN
from pptx.util import Inches, Pt
from build_decks import ( # noqa: E402
H, LOGO, MARGIN, W, Theme, bullets, footer, kicker_title, page_base, rect,
sanitize_package, text,
)
CAPTURES = ROOT.parent / "platform" / "captures"
def picture(slide, name, x, y, w=None, h=None):
"""Insère une capture si elle existe ; sinon ne pose rien."""
path = CAPTURES / name
if not path.exists():
return None
kw = {}
if w is not None:
kw["width"] = w
if h is not None:
kw["height"] = h
return slide.shapes.add_picture(str(path), x, y, **kw)
def slide_cover(prs, th):
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.05), Inches(10.4), Inches(0.4),
[("MÉDIATION NUMÉRIQUE POUR LIEUX CULTURELS", 11, True, th.accent_bright,
th.body_font, 0)])
text(s, MARGIN, Inches(2.6), Inches(10.6), Inches(1.8),
[("MyInfoMate", 52, True, RGBColor(0xFF, 0xFF, 0xFF), th.title_font, 0)],
line_spacing=1.02)
text(s, MARGIN, Inches(3.95), Inches(9.8), Inches(1.2),
[("Votre contenu, saisi une fois.\n"
"Vécu sur une borne, un téléphone et le web.", 16, False,
RGBColor(0xB8, 0xC2, 0xCE), th.body_font, 0)], line_spacing=1.4)
if LOGO.exists():
s.shapes.add_picture(str(LOGO), W - MARGIN - Inches(1.4), Inches(0.8),
height=Inches(1.2))
return s
def slide_problem(prs, th, n):
s = page_base(prs, th)
kicker_title(s, th, "Le constat",
"La médiation coûte cher, et elle vieillit vite.")
items = [
("Le panneau imprimé", "Une correction, et c'est un nouveau tirage. Une "
"langue de plus, et c'est un nouveau panneau."),
("L'audioguide loué", "Un parc de boîtiers à charger, désinfecter et "
"remplacer, pour un contenu qu'on ne peut plus modifier."),
("L'application sur devis", "Livrée une fois, jamais reprise. Le prestataire "
"part, le contenu se fige, et la refonte se repaie entièrement."),
]
cw = (W - 2 * MARGIN - Inches(0.6)) / 3
y = Inches(3.15)
for i, (head, body) in enumerate(items):
x = MARGIN + i * (cw + Inches(0.3))
rect(s, x, y, cw, Inches(2.2), fill=th.surface, line=th.rule)
rect(s, x, y, cw, Pt(3), fill=th.accent)
text(s, x + Inches(0.35), y + Inches(0.35), cw - Inches(0.7), Inches(0.5),
[(head, 15, True, th.ink, th.title_font, 0)], line_spacing=1.18)
text(s, x + Inches(0.35), y + Inches(0.95), cw - Inches(0.7), Inches(1.1),
[(body, 11.5, False, th.muted, th.body_font, 0)], line_spacing=1.35)
text(s, MARGIN, Inches(5.75), Inches(11.0), Inches(0.8),
[("Le point commun : le contenu appartient à l'objet qui le porte. "
"Changez l'objet, vous perdez le contenu.", 14, False, th.ink,
th.body_font, 0)], line_spacing=1.35)
footer(s, th, n, "Le constat")
return s
def slide_promise(prs, th, n):
s = page_base(prs, th)
kicker_title(s, th, "La proposition",
"Vous écrivez votre contenu une fois. Il vit partout, et il reste "
"à vous.")
text(s, MARGIN, Inches(3.35), Inches(11.0), Inches(1.6),
[("MyInfoMate sépare ce que vous racontez de l'endroit où on l'écoute. "
"Votre contenu vit dans un espace qui vous appartient ; la borne, "
"l'application et le site web ne sont que des façons de le consulter.",
17, False, th.ink, th.body_font, 0)], line_spacing=1.45)
rect(s, MARGIN, Inches(5.35), Inches(11.0), Pt(1), fill=th.rule)
text(s, MARGIN, Inches(5.6), Inches(11.0), Inches(0.8),
[("Une correction se fait une fois et se voit partout, le jour même. "
"Sans développeur, sans devis, sans attendre.", 13, False, th.muted,
th.body_font, 0)], line_spacing=1.35)
footer(s, th, n, "La proposition")
return s
def slide_channels(prs, th, n):
s = page_base(prs, th)
kicker_title(s, th, "Trois façons de visiter",
"Un seul contenu, trois expériences.")
hub_w, hub_h = Inches(3.6), Inches(1.4)
hub_x = (W - hub_w) / 2
hub_y = Inches(3.10)
rect(s, hub_x, hub_y, hub_w, hub_h, fill=th.ink)
text(s, hub_x + Inches(0.25), hub_y, hub_w - Inches(0.5), hub_h,
[("Votre contenu", 16, True, RGBColor(0xFF, 0xFF, 0xFF), th.title_font, 0),
("textes, images, sons,\nen dix langues", 11, False,
RGBColor(0xC5, 0xCE, 0xD8), th.body_font, 6)],
align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE, line_spacing=1.2)
cw, ch, gap = Inches(3.35), Inches(1.5), Inches(0.35)
total = 3 * cw + 2 * gap
x0 = (W - total) / 2
y = hub_y + hub_h + Inches(0.8)
apps = [
("La borne en salle", "Grand écran tactile, choix de la langue, "
"retour à l'accueil automatique."),
("L'application mobile", "Sur le téléphone du visiteur. Fonctionne "
"sans réseau."),
("La visite web", "Un QR code, rien à installer. Ouvre dans le "
"navigateur."),
]
for i, (name, body) in enumerate(apps):
x = x0 + i * (cw + gap)
rect(s, x, y, cw, ch, fill=th.surface, line=th.rule)
rect(s, x, y, cw, Pt(3), fill=th.accent)
rect(s, x + cw / 2 - Pt(1), hub_y + hub_h, Pt(2), Inches(0.8), fill=th.rule)
text(s, x + Inches(0.25), y + Inches(0.3), cw - Inches(0.5), Inches(0.3),
[(name, 13.5, True, th.ink, th.title_font, 0)], align=PP_ALIGN.CENTER)
text(s, x + Inches(0.25), y + Inches(0.72), cw - Inches(0.5), Inches(0.7),
[(body, 11, False, th.muted, th.body_font, 0)], align=PP_ALIGN.CENTER,
line_spacing=1.3)
rect(s, x0, hub_y + hub_h + Inches(0.4), total, Pt(2), fill=th.rule)
footer(s, th, n, "Trois façons de visiter")
return s
def slide_feature(prs, th, n, kicker, title, subtitle, items, capture=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.85), Inches(5.75), items, size=12.5,
gap=Inches(0.15))
if capture:
if portrait:
picture(s, capture, Inches(8.9), Inches(1.35), h=Inches(5.4))
else:
picture(s, capture, Inches(7.35), Inches(2.15), w=Inches(5.1))
footer(s, th, n, kicker)
return s
def slide_references(prs, th, n):
s = page_base(prs, th)
kicker_title(s, th, "Ils l'utilisent déjà",
"Trois sites en production, le plus ancien depuis 2021.")
cards = [
("Musée de la Fraise", "Wépion — depuis 2021",
"Une borne tactile en salle, toujours en service cinq ans après sa pose."),
("Fort de Saint-Héribert", "Namur — depuis 2023",
"Un site souterrain sans réseau : la visite mobile fonctionne entièrement "
"hors-ligne, déclenchée par des balises."),
("Visit Namur", "Office de tourisme — depuis 2024",
"Une borne grand format à l'accueil du public."),
]
cw = (W - 2 * MARGIN - Inches(0.6)) / 3
y = Inches(3.15)
for i, (head, sub, body) in enumerate(cards):
x = MARGIN + i * (cw + Inches(0.3))
rect(s, x, y, cw, Inches(2.6), fill=th.surface, line=th.rule)
rect(s, x, y, cw, Pt(3), fill=th.accent)
text(s, x + Inches(0.38), y + Inches(0.38), cw - Inches(0.76), Inches(0.5),
[(head, 15, True, th.ink, th.title_font, 0)], line_spacing=1.18)
text(s, x + Inches(0.38), y + Inches(0.92), cw - Inches(0.76), Inches(0.3),
[(sub, 11, True, th.accent, th.body_font, 0)])
text(s, x + Inches(0.38), y + Inches(1.35), cw - Inches(0.76), Inches(1.1),
[(body, 11.5, False, th.muted, th.body_font, 0)], line_spacing=1.35)
text(s, MARGIN, Inches(6.05), Inches(11.0), Inches(0.6),
[("Le premier site installé n'a jamais été refait. Il a été mis à jour.",
13, False, th.ink, th.body_font, 0)], line_spacing=1.3)
footer(s, th, n, "Ils l'utilisent déjà")
return s
def slide_pricing(prs, th, n):
s = page_base(prs, th)
kicker_title(s, th, "Nos formules",
"Un abonnement mensuel par site. Pas de frais de développement.")
plans = [
("Essentiel", "39 € / mois", ["Visite web par QR code",
"1 Go de médias",
"Statistiques de fréquentation"]),
("Pro", "99 € / mois", ["Borne et application mobile",
"15 Go de médias",
"Assistant IA en option"]),
("Premium", "179 € / mois", ["Tous les canaux",
"50 Go de médias",
"Assistant IA inclus"]),
("Sur mesure", "sur devis", ["Stockage illimité",
"Statistiques avancées",
"Accompagnement dédié"]),
]
cw = (W - 2 * MARGIN - Inches(0.75)) / 4
y = Inches(3.15)
for i, (name, price, feats) in enumerate(plans):
x = MARGIN + i * (cw + Inches(0.25))
rect(s, x, y, cw, Inches(2.75), fill=th.surface, line=th.rule)
rect(s, x, y, cw, Pt(3), fill=th.accent)
text(s, x + Inches(0.3), y + Inches(0.32), cw - Inches(0.6), Inches(0.3),
[(name, 14, True, th.ink, th.title_font, 0)])
text(s, x + Inches(0.3), y + Inches(0.72), cw - Inches(0.6), Inches(0.35),
[(price, 16, True, th.accent, th.body_font, 0)])
cy = y + Inches(1.3)
for f in feats:
text(s, x + Inches(0.3), cy, cw - Inches(0.6), Inches(0.45),
[(f, 10.5, False, th.muted, th.body_font, 0)], line_spacing=1.25)
cy += Inches(0.42)
text(s, MARGIN, Inches(6.15), Inches(11.0), Inches(0.5),
[("Application publiée à votre nom sur les magasins, mode hors-ligne, "
"balises et notifications se règlent site par site, en dehors du plan.",
11, False, th.muted, th.body_font, 0)], line_spacing=1.3)
footer(s, th, n, "Nos formules")
return s
def slide_roadmap(prs, th, n):
s = page_base(prs, th)
kicker_title(s, th, "Ce qui arrive",
"Le produit continue d'avancer, et votre abonnement suit.")
rows = [
("Guide vocal proactif", "En test",
"Le commentaire se déclenche tout seul à l'approche d'une salle. Le "
"visiteur n'a plus rien à manipuler."),
("Lunettes connectées", "En développement",
"Le visiteur regarde une œuvre, pose sa question à voix haute et obtient "
"sa réponse, sans sortir son téléphone."),
("Plan de salles interactif", "Prévu",
"Un plan d'étage cliquable, pour les sites d'intérieur. C'est la demande "
"la plus fréquente."),
("Guide incarné à l'écran", "À l'étude",
"Un personnage qui accueille et répond sur la borne."),
]
y = Inches(3.25)
for name, status, body in rows:
rect(s, MARGIN, y, W - 2 * MARGIN, Inches(0.75), fill=th.surface, line=th.rule)
rect(s, MARGIN, y, Pt(3), Inches(0.75), fill=th.accent)
text(s, MARGIN + Inches(0.28), y + Inches(0.24), Inches(2.85), Inches(0.3),
[(name, 12.5, True, th.ink, th.title_font, 0)])
text(s, MARGIN + Inches(3.25), y + Inches(0.26), Inches(2.1), Inches(0.3),
[(status.upper(), 8.5, True, th.accent, th.body_font, 0)])
text(s, MARGIN + Inches(5.55), y + Inches(0.14), Inches(5.85), Inches(0.5),
[(body, 10, False, th.muted, th.body_font, 0)], line_spacing=1.22)
y += Inches(0.85)
footer(s, th, n, "Ce qui arrive")
return s
def slide_start(prs, th, n):
s = page_base(prs, th)
kicker_title(s, th, "Démarrer",
"Une visite en ligne avant la fin du mois.")
steps = [
("1", "On ouvre votre espace",
"Vos couleurs, votre logo, vos langues. Comptez une journée."),
("2", "Vous saisissez vos contenus",
"Vos équipes de médiation, avec notre accompagnement. Pas de développeur."),
("3", "Vous publiez",
"Le QR code est prêt. La borne et l'application suivent quand vous voulez."),
]
cw = (W - 2 * MARGIN - Inches(0.6)) / 3
y = Inches(3.15)
for i, (num, head, body) in enumerate(steps):
x = MARGIN + i * (cw + Inches(0.3))
rect(s, x, y, cw, Inches(2.3), fill=th.surface, line=th.rule)
rect(s, x, y, cw, Pt(3), fill=th.accent)
text(s, x + Inches(0.38), y + Inches(0.32), cw - Inches(0.76), Inches(0.5),
[(num, 26, True, th.accent, th.title_font, 0)])
text(s, x + Inches(0.38), y + Inches(0.95), cw - Inches(0.76), Inches(0.45),
[(head, 14, True, th.ink, th.title_font, 0)], line_spacing=1.18)
text(s, x + Inches(0.38), y + Inches(1.5), cw - Inches(0.76), Inches(0.7),
[(body, 11, False, th.muted, th.body_font, 0)], line_spacing=1.3)
text(s, MARGIN, Inches(5.85), Inches(11.0), Inches(0.6),
[("Essai gratuit, sans engagement, avec vos vrais contenus.", 15, True,
th.ink, th.body_font, 0)], line_spacing=1.3)
footer(s, th, n, "Démarrer")
return s
def slide_closing(prs, th):
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.4), Inches(10.6), Inches(2.2),
[("Faisons vivre\nvotre lieu autrement.", 40, True,
RGBColor(0xFF, 0xFF, 0xFF), th.title_font, 0)], line_spacing=1.1)
text(s, MARGIN, Inches(4.75), Inches(9.8), Inches(1.0),
[("MyInfoMate — une solution Unov\n"
"myinfomate.be · fransolet.thomas@gmail.com", 15, False,
RGBColor(0xB8, 0xC2, 0xCE), th.body_font, 0)], line_spacing=1.45)
if LOGO.exists():
s.shapes.add_picture(str(LOGO), W - MARGIN - Inches(1.4), Inches(0.8),
height=Inches(1.2))
return s
def build(out_path):
prs = Presentation()
prs.slide_width, prs.slide_height = W, H
th = Theme(branded=True)
n = 2
slide_cover(prs, th)
slide_problem(prs, th, n); n += 1
slide_promise(prs, th, n); n += 1
slide_channels(prs, th, n); n += 1
slide_feature(prs, th, n, "Vous gardez la main",
"Votre équipe met à jour, seule, le jour même.",
"L'outil de saisie est fait pour des chargés de médiation, pas "
"pour des informaticiens.",
["Un texte, une photo, un son : vous corrigez et vous publiez.",
"Dix langues de visite, avec traduction proposée automatiquement.",
"Vous composez vous-même l'écran d'accueil, avec un aperçu en "
"direct de ce que verra le visiteur.",
"Plusieurs comptes, avec des droits différents selon les rôles."],
"cms.png"); n += 1
slide_feature(prs, th, n, "Vos contenus",
"Quatorze façons de raconter, à composer librement.",
"Rien à développer : un nouveau parcours se configure en quelques "
"heures.",
["Article, carte, diaporama, vidéo, audio, document, agenda.",
"Quiz, jeu, parcours, événement, page web.",
"Chaque contenu existe dans toutes vos langues.",
"Vos couleurs et votre logo s'appliquent partout, sans "
"intervention."],
"formats.png"); n += 1
slide_feature(prs, th, n, "Un guide qui répond",
"Vos visiteurs posent leurs questions. À votre contenu.",
"L'assistant ne répond qu'à partir de ce que vous avez écrit. "
"S'il ne sait pas, il le dit — il n'invente rien.",
["Disponible sur la borne, le mobile et le web.",
"Répond dans la langue du visiteur.",
"Vous rédigez vous-même les réponses de repli sur vos sujets "
"sensibles.",
"Aucune donnée nominative conservée."],
"assistant.png"); n += 1
slide_feature(prs, th, n, "Ce que vos visiteurs vous apprennent",
"Les questions restées sans réponse deviennent votre feuille de "
"route.",
"Vous voyez ce que le public cherche et ne trouve pas. C'est "
"rarement ce qu'on imagine.",
["La liste des questions auxquelles rien n'a pu répondre.",
"Fréquentation par jour, contenus les plus et les moins consultés.",
"Quiz terminés, points d'intérêt visités, QR codes scannés.",
"Un rapport exportable pour votre conseil ou votre tutelle."],
"questions.png"); n += 1
slide_feature(prs, th, n, "Parcours et jeux de piste",
"La même mécanique sert la visite guidée et l'animation scolaire.",
"Une suite d'étapes, avec ou sans carte, avec ou sans énigme à "
"résoudre pour avancer.",
["Étapes libres ou imposées, révélées au fur et à mesure.",
"Énigmes bloquantes, minuteur, écran de réussite.",
"Une étape se déclenche par la position, une balise ou un QR code.",
"Vendu comme animation famille ou groupe scolaire."],
"parcours.png"); n += 1
slide_feature(prs, th, n, "Sans réseau, sans installation",
"Une cave, un fort, un site rural : ça marche quand même.",
"Le visiteur télécharge la visite avant d'entrer. Tout reste "
"disponible ensuite, sans aucune connexion.",
["Textes, images et audio stockés sur le téléphone.",
"Des balises posées en salle proposent le bon contenu au bon "
"endroit, sans manipulation.",
"Les statistiques remontent toutes seules au retour du réseau.",
"Peu de solutions du marché le font réellement."],
"horsligne.png", portrait=True); n += 1
slide_feature(prs, th, n, "À votre nom",
"Vos visiteurs voient votre marque, pas la nôtre.",
"Votre lieu a son propre espace : ses couleurs, son icône, son "
"adresse.",
["Application publiée sous le nom de votre lieu sur les magasins.",
"Visite web sur une adresse qui vous est dédiée.",
"Écran d'accueil et de chargement à votre image.",
"Vos contenus restent les vôtres, et restent exportables."],
"marqueblanche_2.png", portrait=True); n += 1
slide_references(prs, th, n); n += 1
slide_pricing(prs, th, n); n += 1
slide_roadmap(prs, th, n); n += 1
slide_start(prs, th, n); n += 1
slide_closing(prs, th)
cp = prs.core_properties
cp.author = "MyInfoMate"
cp.last_modified_by = "MyInfoMate"
cp.title = "MyInfoMate — la visite augmentée de votre lieu"
cp.subject = ""
cp.comments = ""
cp.keywords = ""
cp.category = ""
cp.content_status = ""
cp.identifier = ""
cp.language = "fr-BE"
cp.revision = 1
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
if __name__ == "__main__":
path, count = build(ROOT / "MyInfoMate_prospect.pptx")
print("%s (%d slides)" % (path, count))