💰💌 Si tu te procures un abonnement d'un an au forfait premium de Oui, mais je LLM d'ici le 30 novembre, je t'offre en prime mes quatre formations autodidactes, une valeur de 276 $ ! Clique ici !" 💰💌

Potion Bottle Icon Manuel d'alchimie du code Potion Bottle Icon

Coder dans les étoiles - Créer une carte du ciel astrologique personnalisée avec Python

- 4 829 mots - Temps de lecture estimé: 27 minutes

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.

Mes principaux critères étaient:

🌘 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 :

# 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="...")

Champs de saisie pour le nom et l'adresse courriel du client

Pourquoi cette approche ?

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 :

Carte géographique interactive pour la sélection du lieu de naissance
Confirmation des coordonnées latitude et longitude après ajustement sur la carte

🌘 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 :

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 !

Sélecteurs de date et d'heure de naissance

🌘 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 :

  1. 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.
  2. 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.
  3. Calcul des positions : Le module d’éphémérides swisseph calcule 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 :

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 :

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 :

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 :

Cette fonction orchestre un flux de travail complet en quatre étapes :

  1. Création du PDF : La fonction make_pdf_document est appelée pour générer le document à partir des données astrologiques et du modèle PDF.

  2. 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.

  3. Notification : Un courriel est envoyé au client avec un lien pour télécharger son document personnalisé.

  4. 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 :

# /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 :

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 :

  1. Les signes principaux (en haut de la page) : Présente les trois éléments les plus importants de l’astrologie
  2. 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 :

Symboles du Soleil, de la Lune et de l'Ascendant sur le PDF

# 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 :

🌘 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 :

Carte du ciel astrologique générée intégrée dans le document PDF

# 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 ?

# 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 :

🌘 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.

  1. Gestion de la mémoire : Utilisation de BytesIO pour éviter les fichiers temporaires sur disque
  2. Qualité d’image : Résolution élevée (300 DPI) pour une impression nette
  3. Compatibilité multi-plateforme : Utilisation de PIL/Pillow pour un traitement d’image matricielles.
  4. 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 ?

Shooting Stars IconConsultation ExpressShooting Stars Icon

Bénéficie d'une heure de consultation dédiée avec François pour résoudre tes défis informatiques et stratégiques. Que ce soit pour la migration vers des technologies libres, la sécurisation de tes systèmes, la documentation de tes procédures, la conception de petits systèmes ou l'automatisation de tâches, cette session intensive t'offre des solutions concrètes et un plan d'action clair.

Tu seras libre ensuite de poursuivre avec un forfait de consultation sur mesure ou les programmes DéconstruIT ou Pleine Confiance

Découvre la Consultation Express.
Abonne-toi au fil RSS pour ne rien manquer.

Étiquettes