J’entends souvent dire que les gens passionnés de technologie sont éloignés de la nature humaine, qu’ils et elles sont perdus dans les nuages. Aujourd’hui, on va aller plus loin que les nuages, dans les étoiles !
Le code peut aussi servir à explorer des domaines plus… ésotériques, tout en restant ancré dans la rigueur technique. Aujourd’hui, je présente comment j’ai conçu une application web pour générer des cartes du ciel astrologiques personnalisées pour une cliente. Ce projet m’a permis de ressortir des outils de dessin scientifique pour un usage complètement différent des tableaux de bord statistiques.
Comment prendre des concepts millénaires et les traduire en algorithmes précis, tout en offrant une expérience utilisateur fluide et intuitive ? J’ai voulu atteindre cet objectif avec cette application.
D’abord, j’ai cherché ce qu’on appelle une base de données d’éphémérides. C’est un outil qui permet de localiser les astres dans le ciel à une date et un lieu précis, en se basant sur la rotation de la Terre autour du soleil et autour d’elle-même. Fort heureusement, j’ai pu trouver une librairie de code sous licence libre, Swisseph, qui répondait à ce besoin. Sans ça, nous aurons dû recourir à des services web, et on voulait limiter ça le plus possible.
🌘 L’architecture de l’application : un duo efficace
J’ai opté pour une architecture classique en développement logiciel : un client frontal et un service web du côté du serveur. Comme ça, je pouvais utiliser deux outils indépendants entre eux, reliés avec un contrat de données. J’ai choisi deux technologies que je maîtrise bien, Streamlit pour le frontal et FastAPI pour le côté serveur.
- Le frontal (Streamlit) : C’est l’interface que l’utilisatrice voit, celle qui permet d’interagir avec l’application. J’ai choisi Streamlit pour sa simplicité et sa rapidité de développement. Il permet de créer des applications de données interactives sans se noyer dans la complexité du développement web traditionnel.
Mes principaux critères étaient:
- une application interactive en une seule page
- rechercher des lieux sur une carte géographique
- saisir facilement la date et l’heure de naissance.
- Le côté serveur (FastAPI) : FastAPI est un cadre d’applications Python moderne, rapide et performant, idéal pour construire des APIs robustes sans avoir à se casser la tête avec les protocoles web. La sécurité et les modèles de données font partie intégrante de FastAPI, contrairement aux librairies minimalistes comme Flask. Ici, c’est le cerveau de la création des visuels. Il gère tous les calculs complexes pour la génération de la carte du ciel et, ensuite, il permet de lancer la création des documents. Tout ça se fait en communiquant à l’aide du protocole HTTP sur une interface de programmation (API) REST.
🌘 Les modèles de données : la structure de notre application
Avant de plonger dans le code, il est important de comprendre la structure de données que nous utilisons. Grâce à Pydantic, j’ai défini des modèles typés qui garantissent la cohérence de nos données entre le frontal et le côté serveur de l’application.
Ces modèles sont la fondation de notre application :
BirthInfo: contiens toutes les informations nécessaires pour les calculs astrologiques : date, heure, fuseau horaire et coordonnées géographiques.AstroElement: représente un corps céleste ou un point important (comme l’Ascendant) avec sa position en degrés et son signe du zodiaque.AstologyResponse: contiens tous les résultats des calculs astrologiques, avec chaque planète et l’Ascendant représentés par unAstroElement, ainsi que la liste des positions des maisons.DocumentInfo: rassemble toutes les informations nécessaires pour générer le document PDF, y compris les informations du client et sa date de naissance.
# backend/app/models.py
from pydantic import BaseModel, Field
from typing import List
class BirthInfo(BaseModel):
birth_date: str
birth_time: str
timezone: str
longitude: float
latitude: float
class AstroElement(BaseModel):
position: float # Position en degrés
signe: str # Signe du zodiaque
class AstrologyResponse(BaseModel):
soleil: AstroElement
lune: AstroElement
mercure: AstroElement
venus: AstroElement
mars: AstroElement
jupiter: AstroElement
saturne: AstroElement
uranus: AstroElement
neptune: AstroElement
pluton: AstroElement
ascendant: AstroElement
houses: List[float]
class DocumentInfo(BaseModel):
nom_client: str = Field(description="Nom du client")
courriel_client: str = Field(description="Courriel du client")
info_naissance: BirthInfo = Field(description="Informations sur la date de naissance")
local: bool = False
Cette structure de données nous permet de typer notre code de manière stricte, d’éviter les erreurs et de documenter clairement ce que chaque fonction attend et retourne.
🌘 Le frontal : recueillir des informations de naissance précises
Mon objectif visait à simplifier le plus possible la collecte d’informations. La cliente ne vise pas une clientèle de gens techniques ; elle veut que les utilisatrices puissent accéder rapidement au résultat, le rechercher et le valider visuellement. J’ai donc intégré une composante de géocodage en utilisant GeoNames, et j’affiche les résultats avec le module Folium sur une carte basée sur OpenStreetMap.
🌘 Les coordonnées et le lieu de naissance
Je demande le nom et le courriel, car c’est comme ça que nous avons convenu que nous allons livrer la carte du ciel. Ensuite vient la partie qui demande le plus de précision : le lieu de naissance. J’ai mis en place un système de géocodage pour aider à trouver les coordonnées exactes. Pour raffiner les résultats, je demande d’abord le pays, et j’utilise PyCountry pour obtenir le code international qui sera ensuite envoyé au géocodeur.
Enfin, je demande la date et l’heure de naissance, avec un outil d’entrée qui permet de choisir une précision à la minute près si on le souhaite.
Voici comment je collecte ces informations avec Streamlit :
# frontend/app.py
st.header("Informations personnelles")
nom_client = st.text_input("Entrez le prénom et le nom", help="...")
courriel_client = st.text_input("Entrez l'adresse courriel", help="...")
st.header("Recherche du lieu de naissance")
countries = {country.name: country.alpha_2 for country in pycountry.countries}
selected_country_name = st.selectbox("Sélectionnez le pays", list(countries.keys()), index=...)
selected_country_code = countries[selected_country_name]
if 'place_name' not in st.session_state:
st.session_state.place_name = ""
place_name = st.text_input("Entrez le lieu de naissance", value=st.session_state.place_name, key="place_name_input", help="...")

Pourquoi cette approche ?
- Simplicité : Les composants Streamlit permettent une saisie intuitive
- Validation : Les champs d’aide guident l’utilisatrice dans la saisie
- Gestion d’état :
st.session_statepermet de conserver les données entre les rafraîchissements
J’utilise st.text_input pour le nom et le courriel, et st.selectbox pour le pays. Pour le lieu de naissance, j’ai intégré une gestion de l’état (st.session_state) pour permettre d’itérer avec la carte et pouvoir sélectionner le lieu le plus précis possible. Chaque opération qui génère un rafraîchissement de la carte doit permettre de replacer le point identifié.
Il y a deux niveaux de géocodage. Le premier niveau utilise la base de données locale de la librairie geonames. Puis, s’il n’y a pas de valeur trouvée, le côté serveur va appeler l’API en ligne de Geonames.
# frontend/app.py
if st.button("Vérifier la localisation"):
if place_name and selected_country_code:
geocoding_request = GeocodingRequest(country_name=selected_country_code, place_name=place_name)
response = requests.post(
f"{BACKEND_URL}/geocode",
json=geocoding_request.model_dump(),
auth=(ADMIN_USERNAME, ADMIN_PASSWORD)
)
# ... (gestion des réponses)
if 'geocoding_response' in st.session_state:
geocoding_response = st.session_state.geocoding_response
st.success(f"Nous avons positionné cet endroit avec un cercle bleu : {geocoding_response['place_name']}")
m = folium.Map(location=[geocoding_response['latitude'], geocoding_response['longitude']],
zoom_start=10)
marker = folium.CircleMarker([geocoding_response['latitude'], geocoding_response['longitude']])
marker.add_to(m)
m.add_child(folium.ClickForMarker(popup="Nouvelle position"))
map_data = st_folium(m, width=600, height=600, key="map")
if map_data['last_clicked'] is not None:
new_lat, new_lon = map_data['last_clicked']['lat'], map_data['last_clicked']['lng']
st.session_state.geocoding_response['latitude'] = new_lat
st.session_state.geocoding_response['longitude'] = new_lon
st.success(f"Nouvelles coordonnées : Lat {new_lat:.6f}, Lon {new_lon:.6f}")
else:
st.write(
f"Coordonnées actuelles : Lat {geocoding_response['latitude']:.6f}, Lon {geocoding_response['longitude']:.6f}")
Fonctionnalités clés de cette section :
- Géocodage en deux niveaux : On consulte la base de données locale, puis on appelle une API en ligne si on n’a pas obtenu de résultat
- Interaction carte interactive : Permet d’ajuster précisément la position par clic
- Rétroaction utilisateur : Messages de confirmation et affichage des coordonnées
- Sécurisation : Authentification basique pour l’accès à l’API


🌘 La date et heure de naissance
Ces informations s’avèrent cruciales pour les calculs astrologiques. J’ai intégré des sélecteurs intuitifs pour pouvoir les entrer facilement. Les éphémérides fonctionnent avec le calendrier julien. Nous verrons un peu plus loin comment cette conversion est effectuée.
# frontend/app.py
st.header("Date et heure de naissance")
birth_date = st.date_input("Sélectionnez la date de naissance",
min_value=datetime.now().date() - timedelta(days=365 * 120),
max_value=datetime.now().date() + timedelta(days=1),
value=datetime(1990, 1, 1),
help="Nous utilisons le format AAAA/MM/JJ. Vous pouvez écrire ou utiliser le sélecteur")
birth_time = st.time_input("Sélectionnez l'heure de naissance", step=timedelta(minutes=1),
value=None,
help="Nous utilisons le format HH:MM sur 24 heures. Vous pouvez écrire ou utiliser le sélecteur")
Caractéristiques de ces sélecteurs :
- Plage temporelle réaliste : 120 ans dans le passé, un jour dans le futur
- Précision à la minute : Précision à la minute pour l’heure de naissance
- Interface intuitive : Calendrier visuel et sélecteur horaire
- Valeur par défaut : L’outil est initialisé au 1er janvier 1990 pour faciliter la sélection
Le st.date_input offre un calendrier visuel avec des limites raisonnables (120 ans dans le passé, un jour dans le futur). Le st.time_input permet une sélection précise de l’heure avec un pas d’une minute. J’ai mis par défaut le 1er janvier 1990, pour éviter que les utilisatrices cherchent leur année trop longtemps dans le calendrier !

🌘 Le côté serveur : quand le code rencontre les étoiles
C’est ici que les calculs astronomiques s‘effectuent ! Le côté serveur a été construit avec FastAPI ; il contient le moteur de l’application, construit autour des composantes suivantes : le géocodage, la gestion des dates et des fuseaux horaires et les éphémérides.
🌘 Les calculs astrologiques : la précision de Swisseph
Au cœur de ces calculs, j’utilise la bibliothèque swisseph. Il s’agit d’une référence en matière de calculs astronomiques. Elle assure une grande précision pour les positions des planètes, des signes du zodiaque et des maisons astrologiques en s’appuyant sur les calculs du Jet Propulsion Laboratory de la NASA.
Le processus se déroule ainsi :
- Conversion en jour julien : Les date et heure de naissance sont converties en Jour Julien, une mesure de temps utilisée en astronomie, où la première journée est le 1er janvier 4713 av. J.-C.
- Définition géographique : Les coordonnées de naissance (latitude et longitude) sont utilisées pour localiser précisément le lieu de l’observation, car la carte du ciel est unique pour chaque endroit sur Terre.
- Calcul des positions : Le module d’éphémérides
swissephcalcule ensuite la position écliptique (en degrés le long du zodiaque) de chaque corps céleste (Soleil, Lune, Mercure, etc.) et des cuspides des maisons (les points de départ des douze secteurs de la carte du ciel, qui représentent différents domaines de vie en astrologie).
Voici à quoi ressemble le processus de calcul dans les logs du système :
INFO:app.functions.calculate_astrology_info:Début du calcul astrologique pour 1990-01-19 00:30
INFO:app.functions.calculate_astrology_info:Date et heure de naissance analysées : 1990-01-19 00:30:00
INFO:app.functions.calculate_astrology_info:Date et heure de naissance convertie en UTC : 1990-01-19 05:30:00+00:00
INFO:app.functions.calculate_astrology_info:Jour julien calculé : 2447910.729170082
INFO:app.functions.calculate_astrology_info:Position géographique définie : Lat 45.4890202453737, Long -73.55346679687501
INFO:app.functions.calculate_astrology_info:Position calculée pour soleil : 298.8749455653942, Signe : Capricorne
INFO:app.functions.calculate_astrology_info:Position calculée pour lune : 212.58894068722145, Signe : Scorpion
INFO:app.functions.calculate_astrology_info:Position calculée pour mercure : 279.76196351876985, Signe : Capricorne
INFO:app.functions.calculate_astrology_info:Position calculée pour venus : 298.41141222078994, Signe : Capricorne
INFO:app.functions.calculate_astrology_info:Position calculée pour mars : 262.56835337623943, Signe : Sagittaire
INFO:app.functions.calculate_astrology_info:Position calculée pour jupiter : 92.94224494083682, Signe : Cancer
INFO:app.functions.calculate_astrology_info:Position calculée pour saturne : 287.7525885598731, Signe : Capricorne
INFO:app.functions.calculate_astrology_info:Position calculée pour uranus : 276.82711962043464, Signe : Capricorne
INFO:app.functions.calculate_astrology_info:Position calculée pour neptune : 282.70358490149584, Signe : Capricorne
INFO:app.functions.calculate_astrology_info:Position calculée pour pluton : 227.50023838789662, Signe : Scorpion
INFO:app.functions.calculate_astrology_info:Position calculée pour mc : 212.58894068722145, Signe : Scorpion
INFO:app.functions.calculate_astrology_info:Maisons calculées : ((208.11044752898954, 235.79316604707049, 268.6977403222394, 304.9524062035208, 338.54289188399304, 6.148533134840932, 28.11044752898954, 55.793166047070486, 88.69774032223938, 124.95240620352078, 158.54289188399304, 186.1485331348409), (208.11044752898954, 124.95240620352078, 127.3020797863187, 60.79922448569499, 219.70601613092032, 241.7871054732696, 208.39822641154248, 61.78710547326962))
INFO:app.functions.calculate_astrology_info:Ascendant calculé : Position 208.11044752898954, Signe Balance
INFO:app.functions.calculate_astrology_info:Positions des maisons : [208.11044752898954, 235.79316604707049, 268.6977403222394, 304.9524062035208, 338.54289188399304, 6.148533134840932, 28.11044752898954, 55.793166047070486, 88.69774032223938, 124.95240620352078, 158.54289188399304, 186.1485331348409]
INFO:app.functions.calculate_astrology_info:Calcul astrologique terminé
Ces logs montrent précisément chaque étape du calcul, de l’analyse de la date de naissance à la détermination finale de toutes les positions planétaires et des maisons astrologiques.
On remarque que Pluton est encore considéré comme une planète en astrologie, donc elle est incluse. La mention mc représente le milieu du ciel, et correspond à la 10e maison.
# /app/functions/calculate_astrology_info.py
import swisseph as swe
from app.functions.get_zodiac_sign import get_zodiac_sign
from app.models import BirthInfo, AstrologyResponse, AstroElement
def calculate_astrology_info(birth_info: BirthInfo) -> AstrologyResponse:
# Conversion de la date/heure en Jour Julien et définition de la position géographique
swe.set_topo(birth_info.longitude, birth_info.latitude, 0)
# Liste des corps célestes à calculer
bodies = [
(swe.SUN, "soleil"), (swe.MOON, "lune"), (swe.MERCURY, "mercure"),
(swe.VENUS, "venus"), (swe.MARS, "mars"), (swe.JUPITER, "jupiter"),
(swe.SATURN, "saturne"), (swe.URANUS, "uranus"), (swe.NEPTUNE, "neptune"),
(swe.PLUTO, "pluton"), (swe.MC, "mc")
]
# Calcul des positions et signes pour chaque corps céleste
results = {}
for body, name in bodies:
position = swe.calc_ut(jd, body)[0]
sign = get_zodiac_sign(position[0])
results[name] = AstroElement(position=position[0], signe=sign)
# Calcul de l'Ascendant et des maisons
houses = swe.houses_ex(jd, birth_info.latitude, birth_info.longitude)
ascendant = houses[0][0]
ascendant_sign = get_zodiac_sign(ascendant)
results["ascendant"] = AstroElement(position=ascendant, signe=ascendant_sign)
results["houses"] = [houses[0][i] for i in range(12)]
return AstrologyResponse(**results)
Points clés de cette fonction :
- Précision astronomique : Utilisation de Swisseph pour des calculs fiables
- Configuration géographique :
swe.set_topopour situer le calcul - Corps célestes complets : Soleil, Lune, planètes et point MC
- Calcul des maisons : Détermination des 12 secteurs astrologiques
- Typage strict : Retour d’un objet
AstrologyResponsebien défini
La fonction calculate_astrology_info orchestre tous les calculs astrologiques. Après avoir configuré la position géographique avec swe.set_topo, elle calcule la position de chaque corps céleste et de l’Ascendant en utilisant les fonctions de swisseph. Chaque position est ensuite associée à son signe du zodiaque correspondant.
🌘 La carte du ciel : une visualisation avec Matplotlib
Une fois les calculs effectués, il faut les présenter visuellement ! J’utilise Matplotlib pour dessiner la carte du ciel. Je me rends rapidement realize qu’établir une belle carte du ciel, ce n’est pas évident, car il faut représenter de nombreuses informations de manière claire et esthétique :
- Le zodiaque : Les douze signes astrologiques sont représentés avec leurs symboles et couleurs distinctives, formant la bande écliptique.
- Les astres : Chaque planète (Soleil, Lune, Mercure, etc.) est placée à sa position exacte en degrés et minutes dans le zodiaque, avec son symbole astrologique.
- Les maisons : Les douze maisons astrologiques, qui divisent la carte en secteurs représentant différents domaines de vie, sont délimitées par leurs cuspides et leurs numéros sont affichés.
- La rotation de la carte : Un aspect crucial est la rotation de la carte, qui est ajustée en fonction de l’Ascendant. L’Ascendant correspond au degré du signe du zodiaque qui se lève à l’horizon Est au moment précis de la naissance. C’est ce point qui ancre la carte au lieu et heure de naissance spécifique, rendant chaque carte unique.
- Les aspects : Les relations angulaires précises entre les planètes (les aspects) sont calculées et dessinées. Ces aspects, comme les conjonctions, oppositions ou trigones, sont des indicateurs clés des dynamiques énergétiques dans une carte.
J’ai dû jongler avec les positions pour éviter les chevauchements, un vrai casse-tête de géométrie et de design !
La génération de la carte du ciel suit un processus bien structuré, comme le montrent ces logs :
INFO:app.functions.generate_sky_chart:Début de la génération de la carte du ciel
INFO:app.functions.generate_sky_chart:Configuration de matplotlib terminée
INFO:app.functions.generate_sky_chart:Configuration du cercle du zodiaque terminée
INFO:app.functions.generate_sky_chart:Début de l'ajout des signes du zodiaque
INFO:app.functions.generate_sky_chart:Début du calcul des aspects entre les corps célestes
INFO:app.functions.generate_sky_chart:Ajouter les positions des corps célestes
INFO:app.functions.generate_sky_chart:Ajouter les lignes de connexion pour les symboles déplacés
INFO:app.functions.generate_sky_chart:Ajouter les maisons
INFO:app.functions.generate_sky_chart:Ajouter les maisons
INFO:app.functions.generate_sky_chart:Ajouter une légende
INFO:app.functions.generate_sky_chart:Vérifier si nous avons des éléments de légende
INFO:app.functions.generate_sky_chart:Ajouter les éléments de la légende dans une seule colonne
INFO:app.functions.generate_sky_chart:Ajouter un titre au plot principal
INFO:app.functions.generate_sky_chart:Retourner l'image en SVG
INFO:app.functions.generate_sky_chart:Figure fermée avec succès
Chaque étape est logiquement ordonnée : configuration de Matplotlib, ajout des éléments graphiques (signes, planètes, maisons), calcul des aspects, et enfin génération de l’image SVG finale.
# /app/functions/generate_sky_chart.py
import matplotlib.pyplot as plt
import numpy as np
from io import BytesIO
from app.functions.adjust_angle_ascendant import adjust_angle_ascendant
from app.functions.calculate_aspect import calculate_aspect
from app.functions.create_marker import create_marker
from app.functions.get_aspect_type import get_aspect_type
from app.functions.symboles import celestial_bodies, aspect_info, zodiac_info
def generate_sky_chart(positions, house_cusps):
# Ajuster toutes les positions en fonction de l'Ascendant pour la rotation de la carte
ascendant = positions['Ascendant']
adjusted_positions = {body: adjust_angle_ascendant(angle, ascendant) for body, angle in positions.items()}
fig, ax = plt.subplots(figsize=(20, 16), subplot_kw=dict(projection='polar'))
fig.patch.set_alpha(0)
ax.patch.set_alpha(0)
ax.set_ylim(0, 1.4)
ax.set_yticks([])
ax.set_xticklabels([])
ax.grid(False)
# Dessin des cercles concentriques
inner_radius = 0.6
middle_radius = 1.0
outer_radius = 1.4
for r in [inner_radius, middle_radius, outer_radius]:
circle = plt.Circle((0, 0), r, transform=ax.transData._b, fill=False,
color='gray', linewidth=1, zorder=1)
ax.add_artist(circle)
# Ajout des signes du zodiaque et des lignes de séparation
for i, (sign, symbol, color) in enumerate(zodiac_info):
angle_deg = adjust_angle_ascendant(30 * i, ascendant) % 360
angle_rad = np.radians(angle_deg)
# ... (logique de dessin des sections colorées et des symboles de signes)
# Calcul et dessin des aspects entre les corps célestes
bodies = list(positions.keys())
for i in range(len(bodies)):
for j in range(i + 1, len(bodies)):
body1, body2 = bodies[i], bodies[j]
angle1, angle2 = adjusted_positions[body1], adjusted_positions[body2]
aspect_angle = calculate_aspect(angle1, angle2)
aspect_type = get_aspect_type(aspect_angle)
if aspect_type:
# ... (logique de dessin des lignes d'aspect)
# Ajout des positions des corps célestes avec gestion des collisions
body_positions = []
for body, (symbol, size, color) in celestial_bodies.items():
if body in adjusted_positions:
position_rad = np.radians(adjusted_positions[body])
marker, markersize = create_marker(symbol, size)
# ... (logique de positionnement pour éviter les chevauchements)
ax.plot(position_rad, y_pos, marker=marker, markersize=markersize,
markerfacecolor=color, markeredgecolor=color, zorder=5)
# ... (ajout des degrés et minutes)
# Ajout des maisons
for i, cusp in enumerate(house_cusps):
angle_rad = np.radians(adjust_angle_ascendant(cusp, ascendant))
# ... (logique de dessin des lignes de maisons et des numéros de maisons)
# Sauvegarder l'image en SVG
with BytesIO() as buf:
fig.savefig(buf, format='svg', bbox_inches='tight', pad_inches=0, dpi=300)
svg_data = buf.getvalue()
plt.close(fig)
return svg_data
Caractéristiques techniques de cette fonction :
- Projection polaire : Utilisation de
projection='polar'pour une représentation circulaire - Rotation automatique : Ajustement des positions en fonction de l’Ascendant
- Qualité d’image élevée : Résolution 300 DPI pour une impression nette
- Gestion des couches : Utilisation de
zorderpour superposition correcte - Fond transparent :
fig.patch.set_alpha(0)pour intégration propre - Optimisation mémoire :
plt.close(fig)pour libérer les ressources
La fonction generate_sky_chart transforme les données astrologiques calculées en une représentation graphique. Elle utilise une projection polaire pour créer la carte du ciel, ajuste toutes les positions en fonction de l’Ascendant, et dessine les éléments clés : les cercles concentriques, les signes du zodiaque, les aspects entre planètes, et les maisons. Le résultat est sauvegardé au format SVG pour une qualité d’image optimale.
🌘 La génération du PDF personnalisé avec ReportLab et PyPDF2
Une fois la carte du ciel générée, l’étape finale consiste à créer un document PDF personnalisé. C’est là que j’ai utilisé ReportLab.
Ça m’a permis de produire un résultat professionnel répondant aux exigences graphiques de ma cliente.
Le processus de création du PDF est bien orchestré, comme le montrent ces logs :
INFO:app.functions.make_pdf_document:Starting PDF document creation
INFO:app.functions.make_pdf_document:Fonts registered successfully
INFO:app.functions.make_pdf_document:PDF dimensions set: 7.5x10.0 inches
INFO:app.functions.make_pdf_document:Template PDF opened and PdfWriter created
INFO:app.functions.make_pdf_document:Generating page 1
INFO:app.functions.make_pdf_document:Page 1 generated successfully
INFO:app.functions.make_pdf_document:Page 1 merged with template successfully
INFO:app.functions.make_pdf_document:Temporary file for page 1 removed
INFO:app.functions.make_pdf_document:Generating page 2
INFO:app.functions.make_pdf_document:Page 2 generated successfully
INFO:app.functions.make_pdf_document:Page 2 merged with template successfully
INFO:app.functions.make_pdf_document:Temporary file for page 2 removed
INFO:app.functions.make_pdf_document:Generating page 3
INFO:app.functions.make_pdf_document:Page 3 generated successfully
INFO:app.functions.make_pdf_document:Page 3 merged with template successfully
INFO:app.functions.make_pdf_document:Temporary file for page 3 removed
INFO:app.functions.make_pdf_document:Generating page 4
INFO:app.functions.make_pdf_document:Page 4 generated successfully
INFO:app.functions.make_pdf_document:Page 4 merged with template successfully
INFO:app.functions.make_pdf_document:Temporary file for page 4 removed
INFO:app.functions.make_pdf_document:Generating page 5
INFO:app.functions.make_pdf_document:Page 5 generated successfully
INFO:app.functions.make_pdf_document:Page 5 merged with template successfully
INFO:app.functions.make_pdf_document:Temporary file for page 5 removed
INFO:app.functions.make_pdf_document:Generating page 6
INFO:app.functions.make_pdf_document:Page 6 generated successfully
INFO:app.functions.make_pdf_document:Page 6 merged with template successfully
INFO:app.functions.make_pdf_document:Temporary file for page 6 removed
INFO:app.functions.make_pdf_document:Generating page 7
INFO:app.functions.make_pdf_document:Page 7 generated successfully
INFO:app.functions.make_pdf_document:Page 7 merged with template successfully
INFO:app.functions.make_pdf_document:Temporary file for page 7 removed
INFO:app.functions.make_pdf_document:Generating page 8
INFO:app.functions.make_pdf_document:Page 8 generated successfully
INFO:app.functions.make_pdf_document:Page 8 merged with template successfully
INFO:app.functions.make_pdf_document:Temporary file for page 8 removed
INFO:app.functions.make_pdf_document:PDF document created successfully: documents/doc_16da05eeaf248f8c1ecf7bd6baaf8e0f_20250831.pdf
INFO:app.functions.make_pdf_document:PDF document creation completed successfully
Ces logs illustrent parfaitement le processus systématique de génération PDF : inscription des polices, configuration des dimensions, génération de chaque page (8 pages au total), fusion avec le modèle, et nettoyage des fichiers temporaires.
🌘 La construction du document : le chef d’orchestre
Le routeur /document est le chef d’orchestre de toute l’opération. C’est lui qui coordonne toutes les étapes finales : création du PDF, stockage dans le nuage (S3), envoi par courriel, et nettoyage des fichiers temporaires. Cette fonction constitue le point d’entrée final qui transforme toutes les données astrologiques calculées en un document physique que le client peut recevoir.
# backend/app/routers/document.py
import logging
from fastapi import APIRouter, HTTPException
from app.functions.make_pdf_document import make_pdf_document
from app.models import DocumentInfo
from app.functions.s3_utils import upload_to_s3, get_download_url
from app.functions.email_utils import envoyer_courriel_url
import os
# Création du routeur FastAPI pour cette section de l'API
router = APIRouter()
@router.post("/document")
async def make_document(doc_info: DocumentInfo):
try:
# Étape 1: Création du document PDF
logging.info(f"Début de la création du document pour {doc_info.nom_client}")
document_name = make_pdf_document(doc_info)
local_file_path = f"./documents/{document_name}"
# Étape 2: Gestion du stockage (local ou S3)
if not doc_info.local:
# Configuration du nom du fichier pour S3
s3_file_name = f"documents/{document_name}"
# Téléchargement vers S3 avec vérification du succès
upload_success = upload_to_s3(local_file_path, s3_file_name)
if not upload_success:
raise Exception("Échec du téléchargement vers S3")
logging.info(f"Téléchargement réussi vers S3: {s3_file_name}")
# Obtention de l'URL de téléchargement depuis S3
document_url = get_download_url(s3_file_name)
else:
# Mode local: utilisation du chemin local
document_url = f"./documents/{document_name}"
logging.info(f"URL générée: {document_url}")
# Étape 3: Envoi de l'e-mail au client
to_email = doc_info.courriel_client
logging.info(f"Préparation de l'envoi de l'e-mail à {to_email}")
envoyer_courriel_url(to_email=to_email,
nom_client=doc_info.nom_client,
document_url=document_url)
logging.info(f"E-mail envoyé avec succès à {to_email}")
# Étape 4: Nettoyage des fichiers temporaires
if not doc_info.local:
# Suppression du fichier local après le téléchargement réussi sur S3
os.remove(local_file_path)
logging.info(f"Fichier local supprimé: {local_file_path}")
# Retour d'un message de succès avec toutes les informations
return {
"message": "Document créé avec succès et envoyé par e-mail",
"data": {
"doc_info": doc_info.model_dump(),
"document_name": document_name,
"document_url": document_url
}
}
except Exception as e:
# En cas d'erreur, on logue l'exception et on renvoie une erreur HTTP
logging.error(f"Erreur lors de la création du document: {str(e)}")
raise HTTPException(status_code=500, detail=str(e))
Architecture de cette fonction :
- Orchestration : Coordination de 4 étapes majeures (création, stockage, notification, nettoyage)
- Gestion d’erreurs robuste : Bloc de code utilisant la structure
try ... except, avec une journalisation détaillée - Mode dual : Support à la fois local et infonuagique (S3)
- Sécurisation : Authentification entre le frontal et le serveur
- Rétroaction utilisateur : Retour structuré pour la géolocalisation
- Optimisation des ressources : Nettoyage automatique des fichiers temporaires
Cette fonction orchestre un flux de travail complet en quatre étapes :
-
Création du PDF : La fonction
make_pdf_documentest appelée pour générer le document à partir des données astrologiques et du modèle PDF. -
Stockage : Si l’application n’est pas en mode local, le document est téléchargé sur S3 pour un stockage persistant et accessible en passant par une URL.
-
Notification : Un courriel est envoyé au client avec un lien pour télécharger son document personnalisé.
-
Nettoyage : Les fichiers temporaires locaux sont supprimés pour ne pas encombrer le serveur.
Chaque étape est protégée par des blocs try ... except et une journalisation détaillés pour assurer un suivi précis du processus et faciliter le débogage en cas de problème.
🌘 L’assemblage du PDF avec ReportLab et PyPDF2
La fonction make_pdf_document constitue le noyau de la génération du document. Elle fait plusieurs choses :
- Chargement des polices : J’ai intégré des polices personnalisées pour donner un style unique au document.
- Calculs astrologiques : Elle réutilise les informations astrologiques calculées précédemment.
- Génération de pages dynamiques : Pour chaque section du document, une fonction spécifique (
generate_pg_0001,generate_pg_0002, etc.) est appelée. Ces fonctions créent des pages temporaires avec le contenu dynamique (le nom de l’utilisatrice, la carte du ciel, les interprétations). - Fusion avec un modèle : Ces pages temporaires sont ensuite fusionnées avec un modèle PDF préexistant. Cela me permet de conserver une mise en page cohérente tout en insérant des informations personnalisées.
- Nommage unique : Le fichier PDF final est nommé de manière unique en utilisant un hachage des informations de naissance et la date, garantissant la confidentialité et l’organisation.
# /app/functions/make_pdf_document.py
from app.functions.calculate_astrology_info import calculate_astrology_info
from app.page_generators.generate_pg_0001 import generate_pg_0001
from app.models import DocumentInfo
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
from PyPDF2 import PdfReader, PdfWriter
import os
import hashlib
from datetime import datetime
def make_pdf_document(doc_info: DocumentInfo):
try:
# Enregistrement des polices personnalisées
pdfmetrics.registerFont(TTFont('CongenialBlack', 'static/police/congenial-black.ttf'))
pdfmetrics.registerFont(TTFont('CongenialLight', 'static/police/congenial-light.ttf'))
# Configuration des dimensions du PDF
width_px, height_px = 2250, 3000
width_inch, height_inch = width_px / 300, height_px / 300
# Récupération des informations astrologiques
astro_info = calculate_astrology_info(doc_info.info_naissance)
# Liste des générateurs de pages
page_generators = [generate_pg_0001]
# Chargement du modèle PDF
template_pdf = PdfReader(open("static/pages/cahier_clientes_sans_infos.pdf", "rb"))
output = PdfWriter()
# Génération et fusion des pages
for i, generate_func in enumerate(page_generators, start=1):
temp_page = generate_func(astro_info, doc_info.nom_client, width_inch, height_inch)
template_page_index = i - 1 if i < 4 else i
template_page = template_pdf.pages[template_page_index]
temp_pdf = PdfReader(open(temp_page, "rb"))
template_page.merge_page(temp_pdf.pages[0])
output.add_page(template_page)
os.remove(temp_page)
# Génération d'un nom de fichier unique
client_name = doc_info.nom_client
birth_date = doc_info.info_naissance.birth_date
hash_input = f"{client_name}{birth_date}".encode('utf-8')
file_hash = hashlib.md5(hash_input).hexdigest()
current_date = datetime.now().strftime("%Y%m%d")
output_filename = f"doc_{file_hash}_{current_date}.pdf"
# Sauvegarde du PDF final
documents_dir = "documents"
if not os.path.exists(documents_dir):
os.makedirs(documents_dir)
output_path = os.path.join(documents_dir, output_filename)
with open(output_path, "wb") as output_stream:
output.write(output_stream)
return output_filename
except Exception as e:
raise Exception(f"Erreur lors de la création du document PDF: {e}")
Fonctionnalités clés de cette fonction :
- Polices personnalisées : Enregistrement de polices uniques pour l’identité visuelle
- Intégration dynamique : Fusion de pages générées avec un modèle PDF existant
- Nommage unique : Utilisation de hachage MD5 pour garantir l’unicité des fichiers
- Nettoyage : Suppression des fichiers temporaires après fusion
La fonction make_pdf_document coordonne toute la génération du PDF. Elle commence par enregistrer les polices personnalisées, récupère les informations astrologiques, puis utilise une série de générateurs de pages pour créer chaque section du document. Ces pages temporaires sont ensuite fusionnées avec un modèle PDF existant, et le tout est sauvegardé avec un nom de fichier unique basé sur les informations du client. C’est plus facile d’imbriquer des documents PDF entre eux que d’utiliser différents formats de fichiers.
🌘 Un exemple de génération de page
Pour donner un aperçu plus concret, voici comment la cinquième page est générée, celle qui intègre la carte du ciel et personnalisation supplémentaire. C’est ici que la magie opère vraiment !
La génération de cette page spécifique suit également un processus logique, comme le montre ce log :
INFO:app.page_generators.generate_pg_0005:Getting sign names from DocumentInfo. Signs: Capricorne, Scorpion, Balance
INFO:app.page_generators.generate_pg_0005:Generating content for each sign. Signs: [('Capricorne', 'Soleil'), ('Scorpion', 'Lune'), ('Balance', 'Ascendant')]
INFO:app.page_generators.generate_pg_0005:Generating sky chart
INFO:app.page_generators.generate_pg_0005:Converting sky chart SVG to PNG with transparent background. SVG size: 116260 bytes
INFO:app.page_generators.generate_pg_0005:Sky chart embedded in page 5 successfully
INFO:app.page_generators.generate_pg_0005:Page 5 generated successfully
Ce log montre comment la page 5 est générée : récupération des informations des signes, génération du contenu pour chaque signe, intégration de la carte du ciel, conversion SVG vers PNG, et embedding final dans le PDF.
# backend/app/page_generators/generate_pg_0005.py
import cairosvg
from io import BytesIO
from PIL import Image
from app.functions.get_sky_chart import get_sky_chart
from app.functions.symboles import signes_map_maj
from app.models import AstrologyResponse
from reportlab.pdfgen import canvas
from reportlab.lib.units import inch
from reportlab.lib.utils import ImageReader
from reportlab.lib.colors import HexColor
def generate_pg_0005(astro_info: AstrologyResponse, nom_client: str, width_inch: float, height_inch: float):
temp_pg_0005 = "temp_pg_0005.pdf"
c = canvas.Canvas(temp_pg_0005, pagesize=(width_inch * inch, height_inch * inch))
# Affichage des trois signes principaux (Soleil, Lune, Ascendant)
positions = [
(1.35 * inch, 6.4 * inch), # Gauche (Soleil)
(3.75 * inch, 6.4 * inch), # Centre (Lune)
(6.15 * inch, 6.4 * inch) # Droite (Ascendant)
]
signs = [
(signes_map_maj.get(astro_info.soleil.signe), "Soleil"),
(signes_map_maj.get(astro_info.lune.signe), "Lune"),
(signes_map_maj.get(astro_info.ascendant.signe), "Ascendant")
]
# Ajout des symboles des signes
for (x, y), (sign, title) in zip(positions, signs):
c.setFont("CongenialLight", 16)
c.setFillColor(HexColor("#37567a"))
c.drawCentredString(x, y + 1.1 * inch, sign)
# Ajout de l'image du signe
img_path = os.path.join("static", "signes", f"{list(signes_map_maj.keys()).index(sign) + 1:02d}.{sign.lower()}.png")
img = ImageReader(img_path)
c.drawImage(img, x - 0.75 * inch, y - 0.75 * inch, width=1.5 * inch, height=1.5 * inch, mask='auto')
# Intégration de la carte du ciel
sky_chart_svg = get_sky_chart(astro_info)
# Conversion SVG vers PNG avec fond blanc
png_data = cairosvg.svg2png(bytestring=sky_chart_svg, background_color='transparent')
with Image.open(BytesIO(png_data)) as img:
img = img.convert('RGBA')
white_bg = Image.new('RGBA', img.size, (255, 255, 255, 255))
img_on_bg = Image.alpha_composite(white_bg, img)
# Calcul des dimensions pour un affichage optimal
max_width, max_height = 7 * inch, 4.5 * inch
img_width, img_height = img_on_bg.size
aspect_ratio = img_width / img_height
if aspect_ratio > (max_width / max_height):
chart_width = max_width
chart_height = chart_width / aspect_ratio
else:
chart_height = max_height
chart_width = chart_height * aspect_ratio
# Positionnement centré de la carte
x = (width_inch * inch - chart_width) / 2
y = 0.75 * inch
# Ajout de la carte au PDF
img_byte_arr = BytesIO()
img_on_bg.save(img_byte_arr, format='PNG')
img_byte_arr.seek(0)
c.drawImage(ImageReader(img_byte_arr), x, y, width=chart_width, height=chart_height)
c.save()
return temp_pg_0005
La fonction generate_pg_0005 montre comment nous intégrons la carte du ciel SVG généré précédemment dans le PDF. Elle convertit d’abord l’image SVG en PNG avec un fond blanc pour une meilleure compatibilité, puis la redimensionne et le met au centre de la page. En parallèle, elle affiche les symboles des trois signes astrologiques les plus importants (Soleil, Lune et Ascendant) avec leurs images correspondantes. Cette page est centrale dans le document final, parce qu’elle introduit un élément de personnalisation.
La cinquième page constitue l’élément central du document PDF ; elle présente la carte du ciel personnalisée et les trois signes astrologiques les plus significatifs pour l’utilisatrice. Cette page doit combiner l’esthétisme et l’information, servant de point focal visuel pour la suite du document.
🌘 Structure et organisation de la page
La page est conçue avec une structure claire en deux parties principales :
- Les signes principaux (en haut de la page) : Présente les trois éléments les plus importants de l’astrologie
- La carte du ciel (au centre et en dessous) : Affiche la visualisation complète du ciel au moment de la naissance
🌘 Les signes principaux : soleil, lune et ascendant
Cette section met en avant les trois piliers de l’interprétation astrologique :

# Positionnement précis des trois signes
positions = [
(1.35 * inch, 6.4 * inch), # Position gauche (Soleil)
(3.75 * inch, 6.4 * inch), # Position centrale (Lune)
(6.15 * inch, 6.4 * inch) # Position droite (Ascendant)
]
# Association avec les données astrologiques
signs = [
(signes_map_maj.get(astro_info.soleil.signe), "Soleil"),
(signes_map_maj.get(astro_info.lune.signe), "Lune"),
(signes_map_maj.get(astro_info.ascendant.signe), "Ascendant")
]
Chaque signe est accompagné de son symbole graphique :
# Chargement de l'image du signe zodiacal
img_path = os.path.join("static", "signes", f"{list(signes_map_maj.keys()).index(sign) + 1:02d}.{sign.lower()}.png")
img = ImageReader(img_path)
# Positionnement et dimensionnement de l'image
c.drawImage(img, x - 0.75 * inch, y - 0.75 * inch, width=1.5 * inch, height=1.5 * inch, mask='auto')
Cette approche combine :
- Le symbole textuel (police personnalisée “CongenialLight”)
- L’image graphique du signe
- Une mise en page harmonieuse avec espacement calculé
🌘 La carte du ciel : du SVG au PDF
L’intégration de la carte du ciel représente l’un des aspects techniques les plus complexes de cette page :

# Récupération de la carte SVG générée par Matplotlib
sky_chart_svg = get_sky_chart(astro_info)
# Conversion avec CairoSVG
png_data = cairosvg.svg2png(bytestring=sky_chart_svg, background_color='transparent')
Pourquoi cette conversion ?
- Compatibilité PDF : Le format PDF supporte mieux le format PNG
- Fond transparent : Permets une intégration visuelle propre sur le modèle PDF
- Qualité préservée : CairoSVG assure une conversion fidèle des éléments vectoriels
# Conversion avec fond blanc pour une meilleure lisibilité
with Image.open(BytesIO(png_data)) as img:
img = img.convert('RGBA')
white_bg = Image.new('RGBA', img.size, (255, 255, 255, 255))
img_on_bg = Image.alpha_composite(white_bg, img)
Cette étape permet une lecture optimale de la carte. Elle évite les problèmes de transparence qui pourraient altérer la visualisation.
# Définition des dimensions maximales
max_width, max_height = 7 * inch, 4.5 * inch
# Calcul proportionnel pour préserver le ratio d'aspect
if aspect_ratio > (max_width / max_height):
chart_width = max_width
chart_height = chart_width / aspect_ratio
else:
chart_height = max_height
chart_width = chart_height * aspect_ratio
Ce calcul assure que :
- La carte est toujours lisible
- Le ratio d’aspect original est préservé
- L’espace disponible est utilisé de manière optimale
🌘 Mise en page et alignement
La mise en page détaillée importe beaucoup pour une présentation professionnelle. Dans cette section de code, on s’assure de l’alignement de la carte du ciel dans le document.
# Centrage horizontal de la carte
x = (width_inch * inch - chart_width) / 2
y = 0.75 * inch # Marge supérieure contrôlée
# Intégration finale dans le PDF
c.drawImage(ImageReader(img_byte_arr), x, y, width=chart_width, height=chart_height)
Voici quelques aspects techniques importants pour utiliser ces outils.
- Gestion de la mémoire : Utilisation de
BytesIOpour éviter les fichiers temporaires sur disque - Qualité d’image : Résolution élevée (300 DPI) pour une impression nette
- Compatibilité multi-plateforme : Utilisation de PIL/Pillow pour un traitement d’image matricielles.
- Intégration PDF : ReportLab assure une fusion avec le modèle existant
🌘 Conclusion : l’automatisation au service de l’expérience client
Ce projet me prouve que, loin de se montrer froide et impersonnelle, la technologie peut servir comme formidable outil pour créer des expériences uniques pour une clientèle. En combinant Streamlit, FastAPI, la précision de swisseph et la flexibilité de Matplotlib et ReportLab, j’ai pu construire une application qui permet de produire des documents d’accueil personnalisés pour une cliente. C’est un gros avantage pour sa stratégie de marketing.
Le processus complet, de la saisie des informations à l’envoi final par e-mail, est bien orchestré et robuste, comme le montrent ces logs finaux :
INFO:root:URL générée: ./documents/doc_16da05eeaf248f8c1ecf7bd6baaf8e0f_20250831.pdf
INFO:root:Envoi de l'e-mail à exemple@client.astro
INFO:app.functions.email_utils:Préparation de l'envoi d'un email de bienvenue à exemple@client.astro
INFO:app.functions.email_utils:Email content saved to documents/courriel_2025-08-31 15:44:59.892415.txt
INFO:app.functions.email_utils:Processus d'envoi d'email HTML terminé pour exemple@client.astro
INFO:root:E-mail envoyé à exemple@client.astro
DEBUG:api_analytics:Logging request: {'hostname': 'backend', 'ip_address': '172.21.0.3', 'path': '/document', 'user_agent': 'python-requests/2.32.3', 'method': 'POST', 'status': 200, 'response_time': 4340, 'user_id': None, 'created_at': '2025-08-31T15:44:59.893147'}
Ces logs illustrent la fin du processus : génération de l’URL, préparation de l’e-mail (avec une petite erreur qui est gérée proprement), sauvegarde du contenu, et enfin confirmation de l’envoi. Le système est conçu pour être résilient face aux erreurs tout en maintenant une trace complète des opérations.
🌘 Réflexions finales : entre tradition et innovation
Ce projet soulève une question intéressante : comment concilier des savoirs millénaires avec les technologies modernes ? L’astrologie, discipline ancienne basée sur l’observation des astres, trouve aujourd’hui un nouveau moyen d’expression à travers le code informatique.
Paradoxalement, c’est en utilisant des technologies de point, comme l’intelligence artificielle et le calcul numérique que l’on peut revenir à des formes d’expression plus traditionnelles et personnalisées. La carte du ciel, autrefois dessinée à la main par des astrologues, est maintenant générée avec une précision inégalée.
Cette application démontre que le numérique peut s’avérer très humain. Au contraire, il peut servir de vecteur pour des expériences plus riches et plus personnalisées. La clé réside dans l’équilibre entre la rigueur technique et la sensibilité humaine.
Et toi, quelles traditions anciennes aimerais-tu réinventer par la technologie ?