Créer un générateur CV Word local

Créez une application locale qui transforme un CV PowerPoint en Word éditable.

Introduction

30 Second Summary

Passer d’un CV en présentation à un document éditable semble simple jusqu’au moment où une rubrique disparaît ou qu’une phrase change. Pour un CV, chaque omission peut fausser le parcours présenté.

Dans ce projet, vous allez construire une application locale avec Streamlit qui transforme un CV PowerPoint en document Microsoft Word éditable sans reformuler son texte. La validation humaine contrôle chaque classement avant qu’un audit autorise le téléchargement.

What You'll Build

Votre démonstration se termine par le téléchargement d’un fichier CV_Prénom_Nom.docx dont chaque contenu remonte au CV PowerPoint chargé.

À la fin de ce projet, vous aurez :

  • Une extraction traçable qui affiche chaque paragraphe ou cellule avec sa diapositive et sa provenance.
  • Une validation révisable pour accepter ou corriger la rubrique proposée pour chaque bloc. Le texte source reste intact.
  • Un fichier Word éditable généré avec le modèle par défaut ou un modèle compatible. Le téléchargement reste bloqué jusqu’à la réussite des contrôles de nom, d’exhaustivité, d’unicité et de compatibilité.
  • Secret Mission: Préparer dans Microsoft Word un modèle personnalisé compatible qui conserve sa mise en page.

Y a-t-il des prérequis ?

Il vous faut un fichier CV au format .pptx pour tester le parcours. Le projet suppose un PC Windows équipé de Python et de Visual Studio Code. Aucun compte cloud ni clé API n’est nécessaire.

Before We Start

Votre générateur doit transférer les paragraphes visibles et les cellules de tableau d’un CV PowerPoint vers un document Word éditable sans réécrire le texte source. La validation humaine empêche l’outil de deviner face à un classement ambigu, une forme PowerPoint non prise en charge ou une zone Word incompatible.

Prepare the Windows Environment

A local converter needs a predictable Python runtime. A virtual environment keeps its packages separate from your other Python projects.

Pinned dependencies keep the later code on the verified library versions. Visual Studio Code keeps those files beside the PowerShell session that uses them.

In this step, get ready to:
  • Verify a supported Python version.
  • Create an isolated .venv environment.
  • Install the pinned dependencies for a working Streamlit demonstration.
Verify Python in a VS Code workspace

A VS Code workspace keeps the editor terminal focused on one folder. This makes every setup command apply to the correct project.

  • Press the Windows key to open the search box.
  • Type Visual Studio Code in the search box.
  • Press Enter to open Visual Studio Code.
  • Select File from the menu bar.
  • Select Open Folder....

The folder dialog lets you place the project somewhere easy to find. Your Desktop keeps the local files within reach.

  • Select Desktop in the folder dialog.
  • Select New Folder.
  • Enter your project folder name as the folder name.
  • Select Select Folder.
  • Select Yes, I trust the authors if the Workspace Trust dialog appears.

The Explorer now shows your project folder name. VS Code now treats this folder as the project workspace.

  • Select View from the menu bar.
  • Select Terminal.
  • Select the terminal profile dropdown in the terminal panel.
  • Select PowerShell as the terminal profile.
  • Check the active Python version by running this command:
python --version

What does this check show?

The command prints the Python version used by later setup commands. Versions 3.10 through 3.14 support this project.

✔️ I see a supported version

Your interpreter is ready for the project. You can continue when the output shows Python 3.10 through 3.14.

ⓧ I see an unsupported version

The installed interpreter falls outside the supported range. Install Python 3.14 before creating the environment.

  • Follow the official Windows installation guide to install Python 3.14 with Python Install Manager.
  • Restart Visual Studio Code after the installation completes.
  • Return to your project folder name.
  • Select View from the menu bar.
  • Select Terminal.
  • Run the version check shown above again.

Continue when the new terminal reports Python 3.10 through 3.14.

ⓧ Command not found

PowerShell cannot currently find Python. Python Install Manager supplies the runtime plus the global command.

  • Follow the official Windows installation guide to install Python Install Manager.
  • Install Python 3.14 by following the runtime instructions in that guide.
  • Restart Visual Studio Code after the installation completes.
  • Return to your project folder name.
  • Select View from the menu bar.
  • Select Terminal.
  • Run the version check shown above again.

Continue when PowerShell prints Python 3.10 through 3.14.

Create the isolated environment

The .venv folder holds a project-specific Python installation context. Activating it directs package installations into that isolated environment.

  • Create the virtual environment inside the open project folder by running this command:
python -m venv .venv

What does this command create?

The command creates the .venv folder inside your project. This folder contains the isolated Python environment used by the converter.

  • Confirm that .venv appears in the VS Code Explorer.

Your project now has its own package home. Later installations stay separated from your other Python projects.

Missing the .venv folder?

Confirm that the terminal is inside the folder shown at the top of the Explorer. Rerun the command after the Python version check succeeds.

Ask for help if the environment still does not appear: Help me diagnose why python -m venv .venv does not create a virtual environment in my Windows VS Code workspace.

  • Activate the virtual environment in PowerShell by running this command:
.venv\Scripts\Activate.ps1

What does activation change?

Activation makes the project environment the current Python context. Package commands now target .venv.

  • Confirm that the PowerShell prompt shows the virtual environment name.

That is the isolation in place. Your next installation now belongs only to this project.

Activation not working?

Confirm that the terminal profile is PowerShell. Check that the .venv folder appears in the Explorer before trying the activation command again.

If PowerShell blocks the activation script, help me activate this virtual environment safely.

The dependency file records the exact packages used by the project. Each pin prevents a later installation from silently selecting a different release.

  • Select the new-file icon beside your project folder name in the Explorer.
  • Enter requirements.txt as the file name.
  • Copy the exact package pins into requirements.txt from the block below:
streamlit==1.65.0
python-pptx==1.0.2
python-docx==1.2.0

What do these pins control?

  • Streamlit 1.65.0 provides the local browser interface.
  • python-pptx 1.0.2 reads the visible PowerPoint text structures.
  • python-docx 1.2.0 creates the editable Word document.
  • Save requirements.txt by pressing Ctrl+S.
  • Install the pinned dependencies into the active environment by running this command:
python -m pip install -r requirements.txt

What does this installation use?

The command reads every pinned entry from requirements.txt. The active environment receives those exact package versions.

  • Confirm that PowerShell returns to the active environment prompt without an installation error.

The verified dependency set is now installed inside your isolated environment. Your global Python packages remain untouched.

Dependency installation failed?

Confirm that the virtual environment name still appears in the PowerShell prompt. Check every package name plus every version in requirements.txt against the reference below.

For environment-specific help, help me diagnose this pinned dependency installation.

✔️ Awesome, I've got everything!

Your saved requirements.txt matches the pinned dependency set.

ⓧ I'd like to double check the full code

streamlit==1.65.0
python-pptx==1.0.2
python-docx==1.2.0

What should match?

Your file should contain these three lines in this order. Each package should use the exact version shown.

Finish the local setup

A minimal starter file reserves the application entry point for the next step. The official Streamlit demonstration tests the installation independently.

  • Select the new-file icon beside your project folder name in the Explorer.
  • Enter app.py as the file name.
  • Copy the starter application into app.py from the block below:
import streamlit as st

st.write("# Générateur local de CV Word depuis PowerPoint")
st.write("Préparation de l’application locale de conversion contrôlée.")

What does this starter file do?

  • The import streamlit as st line makes Streamlit available to the file.
  • The two st.write() calls define the starter heading plus the preparation message.
  • Save app.py by pressing Ctrl+S.
  • Confirm that app.py appears beside requirements.txt in the Explorer.

Starter file missing?

Confirm that the file name ends with .py. Check that the Explorer lists the file inside the same folder as requirements.txt.

If the file still looks different, help me compare my starter app.py with the required file.

✔️ Awesome, I've got everything!

Your saved app.py matches the starter application.

ⓧ I'd like to double check the full code

import streamlit as st

st.write("# Générateur local de CV Word depuis PowerPoint")
st.write("Préparation de l’application locale de conversion contrôlée.")

What should match?

Your file should contain this import plus these two messages. The accents and punctuation should match the reference.

Before you run the final check, predict whether the installed package can open its demonstration page in your browser.

  • Launch the Streamlit demonstration from the active environment by running this command:
python -m streamlit hello

What should you see?

Your browser opens the Streamlit demonstration page. PowerShell keeps the demonstration process running while the page is available.

You have now proved that the pinned Streamlit installation works inside .venv. The local application environment is ready.

Browser page did not open?

Confirm that PowerShell still shows the active virtual environment. Check the terminal for a local address that you can open in the browser.

If the demonstration still fails, help me diagnose my local Streamlit launch.

  • Return to the PowerShell terminal after capturing the page.
  • Press Ctrl+C to stop the demonstration process.

Your Windows workspace now has an isolated runtime plus the exact starter files. Next, you will turn app.py into a faithful PPTX extraction interface.

Extract PPTX Content Faithfully

Your isolated Python environment can now run Streamlit. The CV still lives across PowerPoint text boxes, groups, and tables.

This step builds a faithful PPTX extraction layer. Every visible text block keeps its exact wording.

Each block also records its slide number plus its source location. That provenance makes later decisions traceable.

In this step, get ready to:
  • Create the in-memory PPTX upload flow.
  • Extract text in a deterministic visual order.
  • Verify provenance plus unsupported-shape reporting.
Accept the PPTX upload

The upload widget keeps the presentation in memory. Extension filtering narrows the input to .pptx files.

  • In app.py, replace the current contents with this upload interface:
from io import BytesIO

import streamlit as st
from pptx import Presentation


st.write("# Générateur local de CV Word depuis PowerPoint")

pptx_file = st.file_uploader("Fichier CV PowerPoint", type="pptx")

if pptx_file is None:
    st.write("Chargez un fichier PPTX pour commencer.")

What does this code prepare?

  • The BytesIO import prepares an in-memory stream for the presentation bytes.
  • The Presentation import provides the PowerPoint reader used by the extraction helpers.
  • The st.file_uploader widget limits the upload control to PPTX files.
  • Save app.py.
  • Return to the PowerShell terminal from the previous step.
  • Stop the demonstration process by pressing Ctrl+C if it is still running.
  • Start your project application by running this command:
python -m streamlit run app.py

What does this command launch?

Streamlit runs app.py as a local web application. It reloads the page after you save later changes.

  • Switch to the browser tab opened by Streamlit.

You should see the generator title plus a control for uploading a PowerPoint CV. Your project now has its first working input.

Uploader page missing?

Confirm that the activated environment still appears in your PowerShell prompt. Check that the command is running from the project folder containing app.py.

Make sure the terminal remains open while you use the application in your browser.

Help me diagnose why my local Streamlit app does not show the PPTX uploader.

Extract blocks in deterministic order

A visual PowerPoint slide does not expose a ready-made reading order. Sorting shapes by vertical position first creates a repeatable top-to-bottom sequence.

Horizontal position breaks ties from left to right. Recursive traversal applies the same rule inside grouped shapes.

  • In app.py, add append_block between the imports and the first st.write line:
def append_block(
    blocks: list[dict],
    slide_number: int,
    source: str,
    text: str,
) -> None:
    visual_text = text.replace("\v", "\n")
    if not visual_text.strip():
        return
    blocks.append(
        {
            "id": len(blocks),
            "slide": slide_number,
            "source": source,
            "text": visual_text,
        }
    )

What does append_block preserve?

  • The function converts PowerPoint visual line breaks into newline characters that Word can preserve later.
  • Whitespace-only content is skipped because it does not produce a visible source block.
  • Each accepted block receives an identifier plus its slide number and source description.
  • Add the start of extract_shapes directly below append_block using this first section:
def extract_shapes(shapes, slide_number: int, blocks: list[dict], unsupported: list[str]) -> None:
    ordered_shapes = sorted(
        shapes,
        key=lambda shape: (getattr(shape, "top", 0), getattr(shape, "left", 0)),
    )

    for shape in ordered_shapes:
        shape_name = getattr(shape, "name", "Élément")

        if hasattr(shape, "shapes"):
            extract_shapes(shape.shapes, slide_number, blocks, unsupported)
            continue

        extracted = False

How is the traversal ordered?

  • The sorting key compares each shape’s top coordinate before its left coordinate.
  • A grouped shape exposes its own collection of child shapes.
  • The recursive call sends those children through the same deterministic ordering.
  • Continue extract_shapes directly after extracted = False with the table branch:
        if getattr(shape, "has_table", False):
            extracted = True
            for row_index, row in enumerate(shape.table.rows, start=1):
                for column_index, cell in enumerate(row.cells, start=1):
                    if cell.is_spanned:
                        continue
                    for paragraph_number, paragraph in enumerate(
                        cell.text_frame.paragraphs, start=1
                    ):
                        append_block(
                            blocks,
                            slide_number,
                            (
                                f"Tableau {shape_name}, ligne {row_index}, "
                                f"colonne {column_index}, paragraphe {paragraph_number}"
                            ),
                            paragraph.text,
                        )

How are table cells handled?

  • Rows plus cells stay in their internal PowerPoint order.
  • The is_spanned check skips cells covered by a merged cell.
  • The source description records the table name plus row, column, and paragraph numbers.
  • Finish extract_shapes directly below the table branch with the text and unsupported-shape branches:
        if getattr(shape, "has_text_frame", False):
            extracted = True
            for paragraph_number, paragraph in enumerate(
                shape.text_frame.paragraphs, start=1
            ):
                append_block(
                    blocks,
                    slide_number,
                    f"Forme {shape_name}, paragraphe {paragraph_number}",
                    paragraph.text,
                )

        if not extracted:
            unsupported.append(f"Diapositive {slide_number}: {shape_name}")

How are other shapes handled?

  • Text frames contribute one block for each non-empty paragraph.
  • Pictures plus charts have no supported text path in this extraction layer.
  • Every unsupported shape is recorded separately with its slide number and shape name.
  • Add extract_pptx directly below extract_shapes to coordinate the presentation scan:
def extract_pptx(pptx_bytes: bytes) -> tuple[list[dict], list[str]]:
    presentation = Presentation(BytesIO(pptx_bytes))
    blocks: list[dict] = []
    unsupported: list[str] = []

    for slide_number, slide in enumerate(presentation.slides, start=1):
        extract_shapes(slide.shapes, slide_number, blocks, unsupported)

    return blocks, unsupported

What does extract_pptx coordinate?

  • The presentation is opened from the uploaded bytes through BytesIO.
  • Slides are visited in their original PowerPoint order.
  • The function returns extracted blocks separately from unsupported shapes.
  • Save app.py.
  • Return to the browser tab from earlier.

The upload interface should still render after Streamlit reloads the script. This confirms that the extraction helpers are syntactically valid.

Did the app stop after adding the helpers?

Check that each table and text branch remains indented inside the for shape in ordered_shapes loop. Confirm that extract_pptx begins at the left edge of the file.

Help me fix the indentation in my PPTX extraction helpers.

Run the faithful extraction check

The interface now needs to pass uploaded bytes into the helper layer. A protected read keeps an unreadable presentation from stopping the entire application.

  • In app.py, replace the existing if pptx_file is None section with this protected read:
if pptx_file is None:
    st.write("Chargez un fichier PPTX pour commencer.")
else:
    pptx_bytes = pptx_file.getvalue()

    try:
        raw_blocks, unsupported_shapes = extract_pptx(pptx_bytes)
    except Exception as error:
        st.write(f"Erreur de lecture du PPTX: {error}")
        raw_blocks = []
        unsupported_shapes = []

How does the protected read work?

  • The getvalue() call reads the complete upload into memory.
  • A successful read returns the extracted blocks plus the unsupported-shape list.
  • A failed read displays the error while leaving the Streamlit page available.
  • Continue inside the upload branch directly below unsupported_shapes = [] with the extraction display:
    if not raw_blocks:
        st.write("Aucun paragraphe ni cellule textuelle exploitable n’a été trouvé.")
    else:
        for block in raw_blocks:
            st.write(
                f"Bloc {block['id'] + 1} | Diapositive {block['slide']} | "
                f"{block['source']}"
            )
            st.text_area(
                f"Texte source du bloc {block['id'] + 1}",
                value=block["text"],
                disabled=True,
                height="content",
            )

        if unsupported_shapes:
            st.write("## Éléments PPTX non convertis")
            for item in unsupported_shapes:
                st.write(f"- {item}")

What does the interface reveal?

  • Each block displays its identifier plus its slide number and source location.
  • The disabled text area shows the extracted wording without giving the interface a chance to edit it.
  • The unsupported list keeps pictures plus charts visible as extraction gaps.
  • Save app.py.
  • Return to the PowerShell terminal from earlier.
  • Stop the current Streamlit process by pressing Ctrl+C.

Before you run the final check, do you expect the uploaded CV to appear as finished Word sections or as traceable source blocks?

  • Launch the completed extraction stage by running this command:
python -m streamlit run app.py

What does this run verify?

This run loads the complete extraction path from upload to display. The browser provides the visible evidence that the PowerPoint reader can process your CV.

  • Switch to the browser tab opened by Streamlit.
  • Use the upload control to select your .pptx CV.
  • Compare several displayed text areas with the same paragraphs in your PowerPoint file.

You should see every non-empty paragraph or visible table cell as a numbered block. Each block shows its slide plus its precise source location.

If the presentation contains pictures or charts, you should see their slide numbers and shape names under the unsupported-elements heading. Those entries prove that non-text content was detected separately.

The screen has no approved CV rubric yet. This intended shortfall shows that faithful extraction alone cannot decide where a block belongs.

Missing blocks or seeing a read error?

  • Confirm that the selected file uses the .pptx extension.
  • Open the presentation in PowerPoint to confirm that the file is readable.
  • Compare missing table content with merged cells because covered cells are deliberately skipped.

Help me diagnose missing PPTX blocks or an unreadable presentation.

✔️ Awesome, I've got everything!

Your application reads a PPTX in memory and displays traceable source blocks. Save app.py before continuing.

ⓧ I'd like to double check the full code

from io import BytesIO

import streamlit as st
from pptx import Presentation


def append_block(
    blocks: list[dict],
    slide_number: int,
    source: str,
    text: str,
) -> None:
    visual_text = text.replace("\v", "\n")
    if not visual_text.strip():
        return
    blocks.append(
        {
            "id": len(blocks),
            "slide": slide_number,
            "source": source,
            "text": visual_text,
        }
    )


def extract_shapes(shapes, slide_number: int, blocks: list[dict], unsupported: list[str]) -> None:
    ordered_shapes = sorted(
        shapes,
        key=lambda shape: (getattr(shape, "top", 0), getattr(shape, "left", 0)),
    )

    for shape in ordered_shapes:
        shape_name = getattr(shape, "name", "Élément")

        if hasattr(shape, "shapes"):
            extract_shapes(shape.shapes, slide_number, blocks, unsupported)
            continue

        extracted = False

        if getattr(shape, "has_table", False):
            extracted = True
            for row_index, row in enumerate(shape.table.rows, start=1):
                for column_index, cell in enumerate(row.cells, start=1):
                    if cell.is_spanned:
                        continue
                    for paragraph_number, paragraph in enumerate(
                        cell.text_frame.paragraphs, start=1
                    ):
                        append_block(
                            blocks,
                            slide_number,
                            (
                                f"Tableau {shape_name}, ligne {row_index}, "
                                f"colonne {column_index}, paragraphe {paragraph_number}"
                            ),
                            paragraph.text,
                        )

        if getattr(shape, "has_text_frame", False):
            extracted = True
            for paragraph_number, paragraph in enumerate(
                shape.text_frame.paragraphs, start=1
            ):
                append_block(
                    blocks,
                    slide_number,
                    f"Forme {shape_name}, paragraphe {paragraph_number}",
                    paragraph.text,
                )

        if not extracted:
            unsupported.append(f"Diapositive {slide_number}: {shape_name}")


def extract_pptx(pptx_bytes: bytes) -> tuple[list[dict], list[str]]:
    presentation = Presentation(BytesIO(pptx_bytes))
    blocks: list[dict] = []
    unsupported: list[str] = []

    for slide_number, slide in enumerate(presentation.slides, start=1):
        extract_shapes(slide.shapes, slide_number, blocks, unsupported)

    return blocks, unsupported


st.write("# Générateur local de CV Word depuis PowerPoint")

pptx_file = st.file_uploader("Fichier CV PowerPoint", type="pptx")

if pptx_file is None:
    st.write("Chargez un fichier PPTX pour commencer.")
else:
    pptx_bytes = pptx_file.getvalue()

    try:
        raw_blocks, unsupported_shapes = extract_pptx(pptx_bytes)
    except Exception as error:
        st.write(f"Erreur de lecture du PPTX: {error}")
        raw_blocks = []
        unsupported_shapes = []

    if not raw_blocks:
        st.write("Aucun paragraphe ni cellule textuelle exploitable n’a été trouvé.")
    else:
        for block in raw_blocks:
            st.write(
                f"Bloc {block['id'] + 1} | Diapositive {block['slide']} | "
                f"{block['source']}"
            )
            st.text_area(
                f"Texte source du bloc {block['id'] + 1}",
                value=block["text"],
                disabled=True,
                height="content",
            )

        if unsupported_shapes:
            st.write("## Éléments PPTX non convertis")
            for item in unsupported_shapes:
                st.write(f"- {item}")

Your extractor now proves what PowerPoint actually contains. Next, you’ll add local rules that propose a rubric without changing the source text.

Classify Extracted Blocks

Votre application extrait désormais le contenu exact de chaque bloc PowerPoint. Ce relevé brut conserve la provenance du texte sans indiquer sa future rubrique dans le CV.

Vous allez ajouter un classement déterministe fondé sur des règles locales. Chaque suggestion restera explicable et révisable sans traduire ni reformuler le texte source.

In this step, get ready to:
  • Constater que le relevé brut ne permet pas encore de préparer un CV structuré.
  • Ajouter les rubriques autorisées avec leurs règles de comparaison.
  • Afficher une suggestion révisable pour chaque bloc extrait.
Constater la limite du relevé brut

Le premier affichage prouve que l’extraction respecte le fichier source. Il révèle aussi qu’aucune rubrique ne distingue encore une compétence d’une certification ou d’une expérience.

Avant de lancer le contrôle, pensez-vous que l’application peut déjà déterminer la destination de chaque bloc à partir du relevé brut seul ?

  • Revenez dans le terminal PowerShell intégré de votre dossier de projet.
  • Lancez l’application existante en exécutant cette commande :
python -m streamlit run app.py

Ce que montre cette première version

Vous voyez les identifiants et la provenance des blocs extraits. Vous ne voyez encore aucune rubrique proposée.

Cette limite est intentionnelle. Une extraction fidèle fournit les données nécessaires au classement sans décider de leur sens.

  • Chargez le même fichier .pptx que dans l’étape précédente.
  • Repérez un bloc correspondant clairement à une compétence ou à une langue.

Pourquoi des règles locales ?

Des règles déterministes produisent la même suggestion pour le même texte. Le score et le motif rendent chaque décision visible.

Le texte original reste la seule version destinée à l’export. La comparaison utilise une copie normalisée.

Ajouter les règles locales et explicables

La couche de classement a besoin de quatre éléments. Elle utilise une liste fermée de rubriques, une normalisation réservée aux comparaisons, des mots-clés locaux et deux états de contrôle.

  • Dans app.py déjà ouvert, remplacez le groupe d’imports standard autour de BytesIO par ce groupe :
from hashlib import sha256
from io import BytesIO
from pathlib import Path
import re
import unicodedata

À quoi servent ces imports ?

  • sha256 produit une clé stable pour les champs liés au fichier chargé.
  • Path permet de lire le nom du fichier sans son extension.
  • re sépare les éléments du nom de fichier.
  • unicodedata prépare une copie comparable du texte.
  • Ajoutez les constantes suivantes sous les imports de app.py :
STRUCTURAL = "Repère structurel (non exporté)"
UNCLASSIFIABLE = "Non classable"

DATA_CATEGORIES = [
    "Grade",
    "Nombre d’année d’expérience",
    "Compétences > Techniques",
    "Compétences > Outils",
    "Compétences > Fonctionnelles",
    "Compétences > Conseil",
    "Certifications",
    "Langues",
    "Parcours universitaire",
    "Parcours professionnel > Référence",
    "Parcours professionnel > Contexte",
    "Parcours professionnel > Laius",
]

REVIEW_OPTIONS = DATA_CATEGORIES + [STRUCTURAL, UNCLASSIFIABLE]

Comment les choix restent-ils contrôlés ?

DATA_CATEGORIES contient les seules rubriques exportables. REVIEW_OPTIONS ajoute les repères structurels et les données qui demandent une décision humaine.

La future liste de validation utilise directement ces options. Aucun intitulé libre ne peut créer une rubrique imprévue.

  • Ajoutez la normalisation et les titres structurels sous REVIEW_OPTIONS :
def normalize(text: str) -> str:
    decomposed = unicodedata.normalize("NFKD", text)
    without_accents = "".join(
        character for character in decomposed if not unicodedata.combining(character)
    )
    return " ".join(without_accents.casefold().split())


HEADING_TARGETS = {
    normalize("Nom"): STRUCTURAL,
    normalize("Prénom"): STRUCTURAL,
    normalize("Grade"): STRUCTURAL,
    normalize("Nombre d’année d’expérience"): STRUCTURAL,
    normalize("Compétences"): STRUCTURAL,
    normalize("Techniques"): STRUCTURAL,
    normalize("Outils"): STRUCTURAL,
    normalize("Fonctionnelles"): STRUCTURAL,
    normalize("Conseil"): STRUCTURAL,
    normalize("Certifications"): STRUCTURAL,
    normalize("Langues"): STRUCTURAL,
    normalize("Parcours universitaire"): STRUCTURAL,
    normalize("Diplômes"): STRUCTURAL,
    normalize("Parcours professionnel"): STRUCTURAL,
    normalize("Expériences professionnelles"): STRUCTURAL,
    normalize("Références"): STRUCTURAL,
    normalize("Contexte"): STRUCTURAL,
    normalize("Activités réalisées"): STRUCTURAL,
}

Pourquoi normaliser une copie ?

normalize() retire les différences de casse et d’accents dans la valeur comparée. Le bloc d’origine reste inchangé dans son champ text.

HEADING_TARGETS reconnaît les titres déjà présents dans le CV source. Ces repères organisent le document sans être exportés comme des données.

  • Enregistrez app.py.
  • Actualisez l’application dans votre navigateur.

Vous retrouvez le texte extrait avec sa provenance. Ce contrôle confirme que la couche de comparaison n’a pas modifié les blocs sources.

L’application signale une erreur de syntaxe ?

Vérifiez que la liste DATA_CATEGORIES se termine par un crochet fermant. Vérifiez aussi les accolades de HEADING_TARGETS.

Comparer mon bloc de constantes avec le code attendu.

  • Ajoutez le début du dictionnaire RULES sous HEADING_TARGETS :
RULES = {
    "Grade": ("grade", "consultant", "manager", "architecte", "senior", "junior"),
    "Nombre d’année d’expérience": (
        "année d'expérience",
        "années d'expérience",
        "ans d'expérience",
        "expérience totale",
    ),
    "Compétences > Techniques": (
        "python",
        "java",
        "javascript",
        "typescript",
        "sql",
        "cloud",
        "api",
        "architecture",
    ),
    "Compétences > Outils": (
        "outils",
        "git",
        "docker",
        "jira",
        "visual studio code",
        "power bi",
    ),
}

Comment fonctionnent ces premières règles ?

Chaque clé correspond exactement à une rubrique autorisée. Son tuple contient les motifs locaux qui peuvent justifier une suggestion.

Les motifs restent simples à inspecter. Vous pourrez toujours remplacer la suggestion dans l’interface.

  • Dans RULES, insérez les rubriques suivantes juste avant l’accolade fermante :
    "Compétences > Fonctionnelles": (
        "fonctionnel",
        "fonctionnelle",
        "métier",
        "processus",
        "besoin",
    ),
    "Compétences > Conseil": (
        "conseil",
        "accompagnement",
        "atelier",
        "gouvernance",
        "audit",
    ),
    "Certifications": ("certification", "certifié", "certifiée"),
    "Langues": ("langue", "français", "anglais", "espagnol", "allemand"),
    "Parcours universitaire": (
        "diplôme",
        "université",
        "école",
        "master",
        "licence",
        "doctorat",
        "baccalauréat",
    ),

Ce que couvre ce groupe

Ces motifs couvrent les compétences fonctionnelles, le conseil, les certifications, les langues et les diplômes. Chaque motif sert uniquement à calculer une suggestion.

  • Dans RULES, insérez les trois rubriques professionnelles après Parcours universitaire :
    "Parcours professionnel > Référence": (
        "référence",
        "expérience",
        "mission",
        "projet",
        "client",
    ),
    "Parcours professionnel > Contexte": (
        "contexte",
        "organisation",
        "environnement technique",
        "environnement métier",
    ),
    "Parcours professionnel > Laius": (
        "activité",
        "réalisation",
        "responsabilité",
        "tâche",
    ),

Pourquoi trois rubriques professionnelles ?

Une expérience peut contenir une référence, un contexte et des activités. Les séparer permet de conserver leur rôle sans résumer leur contenu.

  • Enregistrez app.py.
  • Actualisez la page de l’application.

L’extraction s’affiche toujours avec les mêmes textes. Ce contrôle protège la fidélité du relevé pendant l’ajout des règles.

  • Ajoutez parse_names_from_filename() juste avant append_block() :
def parse_names_from_filename(filename: str) -> tuple[str, str]:
    stem = Path(filename).stem
    tokens = [
        token
        for token in re.split(r"[_\s]+", stem)
        if token and normalize(token) not in {"cv", "resume"}
    ]
    if not tokens:
        return "", ""
    if len(tokens) == 1:
        return tokens[0], ""
    return tokens[0], " ".join(tokens[1:])

Comment le nom de fichier est-il interprété ?

La fonction retire l’extension et ignore le préfixe du CV. Un fichier nommé CV_Prénom_Nom.pptx produit une proposition de prénom et de nom.

Cette proposition reste provisoire. Les champs de l’interface permettront sa correction.

  • Ajoutez le début de classify_text() juste après extract_pptx() :
def classify_text(text: str, first_name: str, last_name: str) -> tuple[str, int, str]:
    candidate = normalize(text)
    identity_values = {
        normalize(first_name),
        normalize(last_name),
        normalize(f"{first_name} {last_name}"),
        normalize(f"{last_name} {first_name}"),
    } - {""}

    if candidate in identity_values:
        return STRUCTURAL, 100, "nom ou prénom déjà géré par les champs d’identité"

    if candidate in HEADING_TARGETS:
        return STRUCTURAL, 100, "titre de rubrique reconnu"

    scores: list[tuple[float, str, str]] = []

Quels blocs sont traités en premier ?

La fonction vérifie d’abord les valeurs d’identité et les titres de rubrique. Une correspondance exacte devient un repère structurel avec un score de 100.

Cette priorité empêche le nom complet ou un titre comme Langues d’être exporté une seconde fois.

  • Prolongez classify_text() avec le calcul des scores :
    for category, keywords in RULES.items():
        best_score = 0.0
        best_keyword = ""
        for keyword in keywords:
            normalized_keyword = normalize(keyword)
            if candidate == normalized_keyword:
                score = 0.95
            elif candidate.startswith(normalized_keyword + " "):
                score = 0.85
            elif normalized_keyword in candidate:
                score = 0.65
            else:
                score = 0.0
            if score > best_score:
                best_score = score
                best_keyword = keyword
        if best_score:
            scores.append((best_score, category, best_keyword))

    if re.search(r"\b(?:19|20)\d{2}\b", candidate):
        scores.append((0.60, "Parcours professionnel > Référence", "année détectée"))

Comment le score est-il calculé ?

Une égalité exacte reçoit le score le plus élevé. Un motif placé au début reçoit un score intermédiaire.

Un motif présent ailleurs reçoit un score plus faible. Une année ajoute une indication de référence professionnelle.

  • Terminez classify_text() avec la résolution des résultats :
    if not scores:
        return UNCLASSIFIABLE, 0, "aucune règle correspondante"

    top_score = max(score for score, _, _ in scores)
    winners = [entry for entry in scores if entry[0] == top_score]
    winner_categories = {category for _, category, _ in winners}

    if len(winner_categories) != 1:
        return UNCLASSIFIABLE, int(top_score * 100), "plusieurs rubriques à égalité"

    _, category, keyword = winners[0]
    return category, int(top_score * 100), f"motif détecté: {keyword}"

Comment les cas ambigus sont-ils isolés ?

Une absence de motif produit Non classable avec un score nul. Une égalité entre plusieurs rubriques produit le même état avec le meilleur score trouvé.

Une seule rubrique gagnante renvoie son score et le motif détecté. L’application explique ainsi chaque proposition.

  • Ajoutez classify_blocks() sous classify_text() :
def classify_blocks(
    blocks: list[dict], first_name: str, last_name: str
) -> list[dict]:
    classified = []
    for block in blocks:
        category, confidence, reason = classify_text(
            block["text"], first_name, last_name
        )
        classified.append(
            {
                **block,
                "suggested_category": category,
                "category": category,
                "confidence": confidence,
                "reason": reason,
            }
        )
    return classified

Comment les blocs sont-ils enrichis ?

classify_blocks() conserve toutes les propriétés du bloc extrait. Elle ajoute la suggestion, la catégorie révisable, le score et la raison.

La valeur de text traverse cette opération sans modification. Le futur export pourra reprendre exactement le texte extrait.

Une fonction est-elle soulignée en rouge ?

Vérifiez que les trois fragments de classify_text() partagent la même indentation. Vérifiez que classify_blocks() commence sans indentation.

Aidez-moi à corriger l’indentation de mes fonctions de classement.

Afficher et vérifier les suggestions

Le moteur de règles devient utile lorsque chaque résultat apparaît à côté du texte source. L’interface va aussi rendre le prénom et le nom provisoires modifiables.

  • Dans l’interface Streamlit, repérez le bloc else: placé sous if not raw_blocks:.
  • Supprimez l’ancienne boucle qui affichait uniquement les blocs bruts.
  • Ajoutez la préparation de l’identité et du classement dans ce bloc else: :
        guessed_first_name, guessed_last_name = parse_names_from_filename(
            pptx_file.name
        )
        first_name = st.text_input(
            "Prénom",
            value=guessed_first_name,
            required=True,
            key=f"first_{source_token}",
        )
        last_name = st.text_input(
            "Nom",
            value=guessed_last_name,
            required=True,
            key=f"last_{source_token}",
        )

        classified_blocks = classify_blocks(raw_blocks, first_name, last_name)
        reviewed_blocks = []

Pourquoi l’identité intervient-elle dans le classement ?

Le prénom et le nom déduits deviennent des valeurs structurelles. Le moteur peut alors éviter de les traiter comme des données de rubrique.

Les champs restent modifiables. Une correction relance le classement avec les valeurs confirmées.

  • Ajoutez la boucle d’affichage juste après reviewed_blocks = [] :
        st.write("## Validation du classement")
        for block in classified_blocks:
            st.write(
                f"Bloc {block['id'] + 1} | Diapositive {block['slide']} | "
                f"{block['source']}"
            )
            st.text_area(
                f"Texte source du bloc {block['id'] + 1}",
                value=block["text"],
                disabled=True,
                height="content",
                key=f"source_{source_token}_{block['id']}",
            )
            st.write(
                f"Suggestion: {block['suggested_category']} | "
                f"Confiance: {block['confidence']} % | {block['reason']}"
            )
            selected_category = st.selectbox(
                f"Rubrique validée pour le bloc {block['id'] + 1}",
                REVIEW_OPTIONS,
                index=REVIEW_OPTIONS.index(block["suggested_category"]),
                key=f"category_{source_token}_{block['id']}",
            )
            reviewed_blocks.append({**block, "category": selected_category})

        if unsupported_shapes:
            st.write("## Éléments PPTX non convertis")
            for item in unsupported_shapes:
                st.write(f"- {item}")

Ce que présente chaque bloc

  • La zone désactivée affiche le texte source exact avec son identifiant et sa provenance.
  • La ligne de suggestion affiche la rubrique proposée avec son score et son motif.
  • La liste de validation limite le choix à REVIEW_OPTIONS.
  • Les formes non textuelles restent signalées dans une section séparée.
  • Enregistrez app.py.

Avant d’actualiser la page, quels blocs pensez-vous voir classés par un motif précis ? Quels blocs devraient rester Non classable ?

  • Actualisez l’application dans votre navigateur.
  • Chargez le même fichier .pptx.
  • Corrigez les champs Prénom et Nom si le nom du fichier produit une proposition incorrecte.
  • Ouvrez la liste de rubrique d’un bloc clairement identifiable.

Chaque bloc affiche maintenant une suggestion, un score et une raison. Les blocs sans correspondance unique affichent Non classable.

Vous pouvez choisir une autre rubrique autorisée sans modifier la zone du texte source. Votre application sépare désormais la proposition automatique de la décision humaine.

Les suggestions ne s’affichent pas ?

Vérifiez que la boucle parcourt classified_blocks au lieu de raw_blocks. Vérifiez aussi que classify_blocks() apparaît avant le code de l’interface.

Aidez-moi à relier mes blocs extraits à l’interface de classement.

✔️ Awesome, I've got everything!

Votre application conserve le texte source et propose désormais un classement explicable. Les cas ambigus restent visibles pour la prochaine étape de validation.

ⓧ I'd like to double check the full code

from __future__ import annotations

from hashlib import sha256
from io import BytesIO
from pathlib import Path
import re
import unicodedata

import streamlit as st
from pptx import Presentation


STRUCTURAL = "Repère structurel (non exporté)"
UNCLASSIFIABLE = "Non classable"

DATA_CATEGORIES = [
    "Grade",
    "Nombre d’année d’expérience",
    "Compétences > Techniques",
    "Compétences > Outils",
    "Compétences > Fonctionnelles",
    "Compétences > Conseil",
    "Certifications",
    "Langues",
    "Parcours universitaire",
    "Parcours professionnel > Référence",
    "Parcours professionnel > Contexte",
    "Parcours professionnel > Laius",
]

REVIEW_OPTIONS = DATA_CATEGORIES + [STRUCTURAL, UNCLASSIFIABLE]


def normalize(text: str) -> str:
    decomposed = unicodedata.normalize("NFKD", text)
    without_accents = "".join(
        character for character in decomposed if not unicodedata.combining(character)
    )
    return " ".join(without_accents.casefold().split())


HEADING_TARGETS = {
    normalize("Nom"): STRUCTURAL,
    normalize("Prénom"): STRUCTURAL,
    normalize("Grade"): STRUCTURAL,
    normalize("Nombre d’année d’expérience"): STRUCTURAL,
    normalize("Compétences"): STRUCTURAL,
    normalize("Techniques"): STRUCTURAL,
    normalize("Outils"): STRUCTURAL,
    normalize("Fonctionnelles"): STRUCTURAL,
    normalize("Conseil"): STRUCTURAL,
    normalize("Certifications"): STRUCTURAL,
    normalize("Langues"): STRUCTURAL,
    normalize("Parcours universitaire"): STRUCTURAL,
    normalize("Diplômes"): STRUCTURAL,
    normalize("Parcours professionnel"): STRUCTURAL,
    normalize("Expériences professionnelles"): STRUCTURAL,
    normalize("Références"): STRUCTURAL,
    normalize("Contexte"): STRUCTURAL,
    normalize("Activités réalisées"): STRUCTURAL,
}

RULES = {
    "Grade": ("grade", "consultant", "manager", "architecte", "senior", "junior"),
    "Nombre d’année d’expérience": (
        "année d'expérience",
        "années d'expérience",
        "ans d'expérience",
        "expérience totale",
    ),
    "Compétences > Techniques": (
        "python",
        "java",
        "javascript",
        "typescript",
        "sql",
        "cloud",
        "api",
        "architecture",
    ),
    "Compétences > Outils": (
        "outils",
        "git",
        "docker",
        "jira",
        "visual studio code",
        "power bi",
    ),
    "Compétences > Fonctionnelles": (
        "fonctionnel",
        "fonctionnelle",
        "métier",
        "processus",
        "besoin",
    ),
    "Compétences > Conseil": (
        "conseil",
        "accompagnement",
        "atelier",
        "gouvernance",
        "audit",
    ),
    "Certifications": ("certification", "certifié", "certifiée"),
    "Langues": ("langue", "français", "anglais", "espagnol", "allemand"),
    "Parcours universitaire": (
        "diplôme",
        "université",
        "école",
        "master",
        "licence",
        "doctorat",
        "baccalauréat",
    ),
    "Parcours professionnel > Référence": (
        "référence",
        "expérience",
        "mission",
        "projet",
        "client",
    ),
    "Parcours professionnel > Contexte": (
        "contexte",
        "organisation",
        "environnement technique",
        "environnement métier",
    ),
    "Parcours professionnel > Laius": (
        "activité",
        "réalisation",
        "responsabilité",
        "tâche",
    ),
}


def parse_names_from_filename(filename: str) -> tuple[str, str]:
    stem = Path(filename).stem
    tokens = [
        token
        for token in re.split(r"[_\s]+", stem)
        if token and normalize(token) not in {"cv", "resume"}
    ]
    if not tokens:
        return "", ""
    if len(tokens) == 1:
        return tokens[0], ""
    return tokens[0], " ".join(tokens[1:])


def append_block(
    blocks: list[dict],
    slide_number: int,
    source: str,
    text: str,
) -> None:
    visual_text = text.replace("\v", "\n")
    if not visual_text.strip():
        return
    blocks.append(
        {
            "id": len(blocks),
            "slide": slide_number,
            "source": source,
            "text": visual_text,
        }
    )


def extract_shapes(shapes, slide_number: int, blocks: list[dict], unsupported: list[str]) -> None:
    ordered_shapes = sorted(
        shapes,
        key=lambda shape: (getattr(shape, "top", 0), getattr(shape, "left", 0)),
    )

    for shape in ordered_shapes:
        shape_name = getattr(shape, "name", "Élément")

        if hasattr(shape, "shapes"):
            extract_shapes(shape.shapes, slide_number, blocks, unsupported)
            continue

        extracted = False

        if getattr(shape, "has_table", False):
            extracted = True
            for row_index, row in enumerate(shape.table.rows, start=1):
                for column_index, cell in enumerate(row.cells, start=1):
                    if cell.is_spanned:
                        continue
                    for paragraph_number, paragraph in enumerate(
                        cell.text_frame.paragraphs, start=1
                    ):
                        append_block(
                            blocks,
                            slide_number,
                            (
                                f"Tableau {shape_name}, ligne {row_index}, "
                                f"colonne {column_index}, paragraphe {paragraph_number}"
                            ),
                            paragraph.text,
                        )

        if getattr(shape, "has_text_frame", False):
            extracted = True
            for paragraph_number, paragraph in enumerate(
                shape.text_frame.paragraphs, start=1
            ):
                append_block(
                    blocks,
                    slide_number,
                    f"Forme {shape_name}, paragraphe {paragraph_number}",
                    paragraph.text,
                )

        if not extracted:
            unsupported.append(f"Diapositive {slide_number}: {shape_name}")


def extract_pptx(pptx_bytes: bytes) -> tuple[list[dict], list[str]]:
    presentation = Presentation(BytesIO(pptx_bytes))
    blocks: list[dict] = []
    unsupported: list[str] = []

    for slide_number, slide in enumerate(presentation.slides, start=1):
        extract_shapes(slide.shapes, slide_number, blocks, unsupported)

    return blocks, unsupported


def classify_text(text: str, first_name: str, last_name: str) -> tuple[str, int, str]:
    candidate = normalize(text)
    identity_values = {
        normalize(first_name),
        normalize(last_name),
        normalize(f"{first_name} {last_name}"),
        normalize(f"{last_name} {first_name}"),
    } - {""}

    if candidate in identity_values:
        return STRUCTURAL, 100, "nom ou prénom déjà géré par les champs d’identité"

    if candidate in HEADING_TARGETS:
        return STRUCTURAL, 100, "titre de rubrique reconnu"

    scores: list[tuple[float, str, str]] = []
    for category, keywords in RULES.items():
        best_score = 0.0
        best_keyword = ""
        for keyword in keywords:
            normalized_keyword = normalize(keyword)
            if candidate == normalized_keyword:
                score = 0.95
            elif candidate.startswith(normalized_keyword + " "):
                score = 0.85
            elif normalized_keyword in candidate:
                score = 0.65
            else:
                score = 0.0
            if score > best_score:
                best_score = score
                best_keyword = keyword
        if best_score:
            scores.append((best_score, category, best_keyword))

    if re.search(r"\b(?:19|20)\d{2}\b", candidate):
        scores.append((0.60, "Parcours professionnel > Référence", "année détectée"))

    if not scores:
        return UNCLASSIFIABLE, 0, "aucune règle correspondante"

    top_score = max(score for score, _, _ in scores)
    winners = [entry for entry in scores if entry[0] == top_score]
    winner_categories = {category for _, category, _ in winners}

    if len(winner_categories) != 1:
        return UNCLASSIFIABLE, int(top_score * 100), "plusieurs rubriques à égalité"

    _, category, keyword = winners[0]
    return category, int(top_score * 100), f"motif détecté: {keyword}"


def classify_blocks(
    blocks: list[dict], first_name: str, last_name: str
) -> list[dict]:
    classified = []
    for block in blocks:
        category, confidence, reason = classify_text(
            block["text"], first_name, last_name
        )
        classified.append(
            {
                **block,
                "suggested_category": category,
                "category": category,
                "confidence": confidence,
                "reason": reason,
            }
        )
    return classified


st.write("# Générateur local de CV Word depuis PowerPoint")
st.write(
    "Le texte source reste inchangé. Les règles proposent un classement, "
    "mais l’utilisateur valide chaque décision avant génération."
)

pptx_file = st.file_uploader("Fichier CV PowerPoint", type="pptx")

if pptx_file is None:
    st.write("Chargez un fichier PPTX pour commencer.")
else:
    pptx_bytes = pptx_file.getvalue()
    source_token = sha256(pptx_bytes).hexdigest()[:12]

    try:
        raw_blocks, unsupported_shapes = extract_pptx(pptx_bytes)
    except Exception as error:
        st.write(f"Erreur de lecture du PPTX: {error}")
        raw_blocks = []
        unsupported_shapes = []

    if not raw_blocks:
        st.write("Aucun paragraphe ni cellule textuelle exploitable n’a été trouvé.")
    else:
        guessed_first_name, guessed_last_name = parse_names_from_filename(
            pptx_file.name
        )
        first_name = st.text_input(
            "Prénom",
            value=guessed_first_name,
            required=True,
            key=f"first_{source_token}",
        )
        last_name = st.text_input(
            "Nom",
            value=guessed_last_name,
            required=True,
            key=f"last_{source_token}",
        )

        classified_blocks = classify_blocks(raw_blocks, first_name, last_name)
        reviewed_blocks = []

        st.write("## Validation du classement")
        for block in classified_blocks:
            st.write(
                f"Bloc {block['id'] + 1} | Diapositive {block['slide']} | "
                f"{block['source']}"
            )
            st.text_area(
                f"Texte source du bloc {block['id'] + 1}",
                value=block["text"],
                disabled=True,
                height="content",
                key=f"source_{source_token}_{block['id']}",
            )
            st.write(
                f"Suggestion: {block['suggested_category']} | "
                f"Confiance: {block['confidence']} % | {block['reason']}"
            )
            selected_category = st.selectbox(
                f"Rubrique validée pour le bloc {block['id'] + 1}",
                REVIEW_OPTIONS,
                index=REVIEW_OPTIONS.index(block["suggested_category"]),
                key=f"category_{source_token}_{block['id']}",
            )
            reviewed_blocks.append({**block, "category": selected_category})

        if unsupported_shapes:
            st.write("## Éléments PPTX non convertis")
            for item in unsupported_shapes:
                st.write(f"- {item}")

Le classement local est prêt. La prochaine étape vous permettra de valider l’identité, chaque rubrique et le contrat du modèle Word avant toute génération.

Validate Data and Word Templates

Votre application Streamlit sait extraire le contenu de PowerPoint. Elle propose aussi un classement local pour chaque bloc.

Une règle peut produire une suggestion ambiguë. Un modèle Microsoft Word arbitraire peut contenir des exemples ou des zones impossibles à vérifier. Cette étape ajoute une validation humaine ainsi qu’un contrat explicite pour chaque modèle DOCX.

In this step, get ready to:
  • Exiger un prénom et un nom révisables avant la génération.
  • Contrôler les emplacements présents dans un modèle Word facultatif.
  • Signaler chaque donnée ambiguë ou incompatible avec le modèle choisi.
Exiger une identité complète

Le nom du fichier fournit une première proposition d’identité. Les champs restent modifiables afin que la personne qui valide le CV conserve le dernier mot.

  • Dans app.py, trouvez le chargeur PowerPoint actuel:
pptx_file = st.file_uploader("Fichier CV PowerPoint", type="pptx")

Que charge ce champ ?

Ce champ limite le document source au format PPTX. Le contenu reste traité localement par l’application.

  • Remplacez cette ligne par les deux chargeurs suivants:
pptx_file = st.file_uploader("Fichier CV PowerPoint", type="pptx")
template_file = st.file_uploader("Modèle Word facultatif", type="docx")

Pourquoi deux fichiers ?

  • Le fichier PowerPoint reste la source des données du CV.
  • Le fichier Word devient un modèle facultatif dont la compatibilité doit être prouvée.
  • Dans app.py, repérez les champs construits juste après parse_names_from_filename.
  • Remplacez les deux champs d’identité par ce bloc:
        first_name = st.text_input(
            "Prénom",
            value=guessed_first_name,
            required=True,
            key=f"first_{source_token}",
        )
        last_name = st.text_input(
            "Nom",
            value=guessed_last_name,
            required=True,
            key=f"last_{source_token}",
        )

Comment l’identité reste-t-elle contrôlable ?

  • La valeur initiale vient toujours du nom du fichier PowerPoint.
  • Le paramètre required=True indique que chaque champ doit contenir une valeur.
  • Les clés liées à source_token isolent l’état des champs pour chaque fichier chargé.
  • Enregistrez app.py.
  • Relancez l’application depuis le terminal PowerShell de Visual Studio Code en exécutant:
python -m streamlit run app.py

Quel changement devez-vous voir ?

L’application affiche maintenant le champ Modèle Word facultatif. Les champs Prénom et Nom apparaissent comme obligatoires après le chargement du PPTX.

Le nouveau chargeur est absent ?

Vérifiez que template_file se trouve directement sous pptx_file. Confirmez aussi que le fichier enregistré est bien app.py.

Aidez-moi à retrouver pourquoi le chargeur DOCX ou les champs obligatoires ne s’affichent pas dans mon application Streamlit.

Inspecter le contrat du modèle Word

Un modèle compatible expose chaque zone dynamique avec un emplacement reconnu. L’analyse parcourt le corps du document ainsi que ses tableaux.

Le même contrôle couvre les en-têtes ainsi que les pieds de page. Les zones de texte flottantes restent bloquées car leur contenu ne peut pas être modifié avec la même garantie.

  • Dans la zone des imports de app.py, ajoutez les imports Word ainsi que les imports ZIP avec ce groupe:
from zipfile import BadZipFile, ZipFile

import streamlit as st
from docx import Document
from pptx import Presentation

Pourquoi lire aussi le fichier ZIP ?

  • Document donne accès aux paragraphes ainsi qu’aux tableaux du modèle Word.
  • ZipFile permet d’inspecter les fichiers XML internes du document.
  • BadZipFile identifie un fichier illisible comme archive DOCX.
  • Sous REVIEW_OPTIONS, ajoutez le contrat entre les rubriques et les emplacements Word:
CATEGORY_MARKERS = {
    "Grade": "{{ grade }}",
    "Nombre d’année d’expérience": "{{ annees_experience }}",
    "Compétences > Techniques": "{{ competences_techniques }}",
    "Compétences > Outils": "{{ competences_outils }}",
    "Compétences > Fonctionnelles": "{{ competences_fonctionnelles }}",
    "Compétences > Conseil": "{{ competences_conseil }}",
    "Certifications": "{{ certifications }}",
    "Langues": "{{ langues }}",
    "Parcours universitaire": "{{ parcours_universitaire }}",
    "Parcours professionnel > Référence": "{{ references_professionnelles }}",
    "Parcours professionnel > Contexte": "{{ contextes_professionnels }}",
    "Parcours professionnel > Laius": "{{ activites_professionnelles }}",
}

IDENTITY_MARKERS = {
    "Nom": "{{ nom }}",
    "Prénom": "{{ prenom }}",
}

ALL_MARKERS = set(CATEGORY_MARKERS.values()) | set(IDENTITY_MARKERS.values())

Que définit ce contrat ?

  • CATEGORY_MARKERS associe chaque rubrique exportable à un emplacement unique.
  • IDENTITY_MARKERS réserve les emplacements {{ nom }} et {{ prenom }}.
  • ALL_MARKERS rassemble toutes les valeurs acceptées pendant l’inspection.
  • Sous RULES, ajoutez les textes statiques autorisés dans le modèle:
ALLOWED_STATIC_HEADINGS = {
    normalize("Nom"),
    normalize("Prénom"),
    normalize("Grade"),
    normalize("Nombre d’année d’expérience"),
    normalize("Compétences"),
    normalize("Techniques"),
    normalize("Outils"),
    normalize("Fonctionnelles"),
    normalize("Conseil"),
    normalize("Certifications"),
    normalize("Langues"),
    normalize("Parcours universitaire"),
    normalize("Parcours professionnel"),
    normalize("Références professionnelles"),
    normalize("Contextes professionnels"),
    normalize("Activités professionnelles"),
}

Pourquoi autoriser ces textes ?

Ces libellés décrivent la structure du document. Tout autre texte statique peut être une ancienne donnée d’exemple qui exige une confirmation humaine.

  • Enregistrez app.py.
  • Vérifiez que l’application accepte les nouveaux imports en exécutant:
python -m streamlit run app.py

Que prouve ce contrôle ?

La page Streamlit se charge avec le sélecteur DOCX. Le terminal ne signale aucune erreur liée à Document ou à ZipFile.

  • Sous classify_blocks, ajoutez les parcours récursifs des tableaux Word:
def iter_table_paragraphs(table):
    for row in table.rows:
        for cell in row.cells:
            for paragraph in cell.paragraphs:
                yield paragraph
            for nested_table in cell.tables:
                yield from iter_table_paragraphs(nested_table)


def iter_container_paragraphs(container):
    for paragraph in container.paragraphs:
        yield paragraph
    for table in container.tables:
        yield from iter_table_paragraphs(table)

Comment les tableaux sont-ils parcourus ?

  • iter_table_paragraphs visite les paragraphes de chaque cellule.
  • La récursion inclut les tableaux imbriqués dans une cellule.
  • iter_container_paragraphs applique ce parcours à un document ou à une zone d’en-tête.
  • Ajoutez ensuite le parcours du document principal ainsi que de ses sections:
def iter_all_paragraphs(document):
    yield from iter_container_paragraphs(document)

    container_names = (
        "header",
        "even_page_header",
        "first_page_header",
        "footer",
        "even_page_footer",
        "first_page_footer",
    )
    for section in document.sections:
        for container_name in container_names:
            container = getattr(section, container_name)
            if container.is_linked_to_previous:
                continue
            yield from iter_container_paragraphs(container)

Pourquoi inspecter chaque section ?

Un emplacement peut se trouver dans le corps du document ou dans une zone répétée. Le contrôle ignore les en-têtes liés à une section précédente afin de ne pas compter deux fois le même contenu.

  • Ajoutez la détection des zones de texte flottantes ainsi que le nettoyage des libellés statiques:
def contains_textbox(docx_bytes: bytes) -> bool:
    try:
        with ZipFile(BytesIO(docx_bytes)) as archive:
            for name in archive.namelist():
                if name.startswith("word/") and name.endswith(".xml"):
                    xml = archive.read(name)
                    if b":txbx" in xml or b":textbox" in xml:
                        return True
    except BadZipFile:
        return True
    return False


def clean_static_label(text: str) -> str:
    return normalize(text).strip(" :;.-")

Que bloque cette détection ?

  • contains_textbox cherche les balises internes associées aux zones de texte Word.
  • Un fichier ZIP illisible est traité comme incompatible.
  • clean_static_label prépare les libellés pour une comparaison locale avec la liste autorisée.
  • Enregistrez app.py.
  • Contrôlez le chargement des fonctions en exécutant:
python -m streamlit run app.py

Quel résultat confirme les parcours ?

L’application se recharge sans erreur Python. Le chargeur du modèle Word reste disponible dans la page.

Une erreur apparaît au démarrage ?

Vérifiez l’indentation de chaque fonction. Confirmez que contains_textbox se termine avant la définition de clean_static_label.

Aidez-moi à corriger l’indentation des fonctions de parcours et de détection DOCX dans app.py.

Le rapport du modèle doit distinguer les emplacements valides des anomalies. Il doit aussi isoler chaque texte statique qui pourrait provenir d’un ancien CV.

  • Sous clean_static_label, ajoutez la première partie de scan_template:
def scan_template(template_bytes: bytes) -> dict:
    document = Document(BytesIO(template_bytes))
    paragraphs = list(iter_all_paragraphs(document))
    paragraph_texts = [paragraph.text for paragraph in paragraphs if paragraph.text]
    combined = "\n".join(paragraph_texts)

    marker_counts = {
        marker: combined.count(marker) for marker in ALL_MARKERS if marker in combined
    }
    found_markers = set(marker_counts)

    split_markers = set()
    for paragraph in paragraphs:
        for marker in found_markers:
            if marker in paragraph.text and not any(
                marker in run.text for run in paragraph.runs
            ):
                split_markers.add(marker)

    all_tags = set(re.findall(r"\{\{.*?\}\}", combined))
    unknown_tags = all_tags - ALL_MARKERS

Que mesure la première partie ?

  • marker_counts compte chaque emplacement reconnu dans le texte combiné.
  • split_markers repère un emplacement réparti entre plusieurs runs Word.
  • unknown_tags conserve les balises qui ne figurent pas dans le contrat du projet.
  • Dans la même fonction, ajoutez ce bloc directement sous unknown_tags = all_tags - ALL_MARKERS:
    suspicious = set()
    for text in paragraph_texts:
        residual = re.sub(r"\{\{.*?\}\}", "", text).strip()
        if not residual:
            continue
        if not re.search(r"[A-Za-zÀ-ÿ0-9]", residual):
            continue
        if clean_static_label(residual) not in ALLOWED_STATIC_HEADINGS:
            suspicious.add(residual)

    return {
        "found_markers": found_markers,
        "duplicate_markers": {
            marker: count for marker, count in marker_counts.items() if count != 1
        },
        "split_markers": split_markers,
        "unknown_tags": unknown_tags,
        "suspicious_text": sorted(suspicious),
        "has_textbox": contains_textbox(template_bytes),
    }

Que contient le rapport final ?

Le dictionnaire rassemble les emplacements trouvés ainsi que chaque motif de blocage. Les textes non reconnus sont triés pour produire une liste stable à confirmer.

Bloquer les modèles et les données incompatibles

Le rapport technique devient maintenant une décision visible dans l’interface. Un modèle arbitraire reste bloqué jusqu’à ce que chaque anomalie soit résolue ou confirmée.

  • Après l’affichage des formes PowerPoint non prises en charge, ajoutez le choix entre le modèle par défaut et un modèle chargé:
        template_ok = False
        available_markers = set(ALL_MARKERS)

        if template_file is None:
            st.write("## Modèle par défaut")
            template_ok = st.checkbox(
                "Je confirme l’utilisation exclusive du modèle par défaut défini.",
                key=f"default_{source_token}",
            )
        else:
            st.write("## Contrôle du modèle Word")
            template_bytes = template_file.getvalue()
            template_token = sha256(template_bytes).hexdigest()[:12]

Comment les deux chemins diffèrent-ils ?

  • Le chemin par défaut demande une confirmation explicite avant de poursuivre.
  • Le chemin personnalisé lit les octets du DOCX pour construire son rapport.
  • template_token fournit une clé stable aux confirmations propres au modèle chargé.

La validation du modèle forme un seul bloc try. Assemblez les quatre morceaux suivants dans l’ordre avant d’enregistrer le fichier.

  • Sous la création de template_token, ajoutez le début de l’analyse:
            try:
                template_report = scan_template(template_bytes)
                available_markers = template_report["found_markers"]

                if template_report["has_textbox"]:
                    st.write(
                        "Génération bloquée: le modèle contient une zone de texte "
                        "Word flottante non prise en charge."
                    )

                missing_identity = set(IDENTITY_MARKERS.values()) - available_markers
                if missing_identity:
                    st.write(
                        "Génération bloquée: emplacements d’identité manquants: "
                        + ", ".join(sorted(missing_identity))
                    )

Quels contrôles passent en premier ?

L’application bloque immédiatement les zones de texte flottantes. Elle vérifie ensuite la présence des deux emplacements d’identité.

  • Poursuivez le même bloc avec les contrôles des emplacements reconnus:
                if not available_markers:
                    st.write(
                        "Génération bloquée: aucun emplacement reconnu. Ajoutez les "
                        "emplacements affichés par le code dans une copie du modèle."
                    )

                if template_report["duplicate_markers"]:
                    st.write(
                        "Génération bloquée: emplacements dupliqués: "
                        + ", ".join(
                            sorted(template_report["duplicate_markers"].keys())
                        )
                    )

                if template_report["split_markers"]:
                    st.write(
                        "Génération bloquée: emplacements fractionnés entre plusieurs "
                        "runs Word: "
                        + ", ".join(sorted(template_report["split_markers"]))
                    )

                if template_report["unknown_tags"]:
                    st.write(
                        "Génération bloquée: emplacements inconnus: "
                        + ", ".join(sorted(template_report["unknown_tags"]))
                    )

Quand un emplacement bloque-t-il le modèle ?

  • Un modèle sans emplacement reconnu ne peut pas recevoir les données.
  • Un emplacement dupliqué compromet le contrôle d’unicité.
  • Un emplacement fractionné ne peut pas être remplacé dans un seul run.
  • Une balise inconnue sort du contrat défini par ALL_MARKERS.
  • Ajoutez ensuite les confirmations des textes statiques suspects:
                static_confirmations = []
                if template_report["suspicious_text"]:
                    st.write(
                        "Confirmez que chaque texte suivant est structurel et non une "
                        "donnée d’exemple."
                    )
                    for index, text in enumerate(
                        template_report["suspicious_text"], start=1
                    ):
                        st.text_area(
                            f"Texte statique potentiel {index}",
                            value=text,
                            disabled=True,
                            height="content",
                            key=f"static_text_{template_token}_{index}",
                        )
                        static_confirmations.append(
                            st.checkbox(
                                "Ce texte appartient au modèle et doit être conservé.",
                                key=f"static_ok_{template_token}_{index}",
                            )
                        )

Pourquoi demander une confirmation ?

Un texte inconnu peut être un élément légitime de la mise en page. Il peut aussi être une donnée d’exemple laissée dans le modèle. La confirmation humaine tranche ce cas sans modifier le texte automatiquement.

  • Terminez le bloc avec la décision de compatibilité et la gestion des fichiers illisibles:
                static_ok = all(static_confirmations)
                template_ok = (
                    bool(available_markers)
                    and not template_report["has_textbox"]
                    and not missing_identity
                    and not template_report["duplicate_markers"]
                    and not template_report["split_markers"]
                    and not template_report["unknown_tags"]
                    and static_ok
                )
            except Exception as error:
                st.write(f"Modèle Word illisible ou incompatible: {error}")
                template_ok = False
                available_markers = set()

Quand le modèle devient-il compatible ?

template_ok devient vrai uniquement lorsque tous les contrôles sont satisfaits. Une erreur de lecture vide aussi available_markers afin d’empêcher l’utilisation du modèle.

  • Enregistrez app.py.
  • Créez un document vide dans Microsoft Word.
  • Enregistrez ce document dans le dossier du projet sous le nom modele-sans-emplacements.docx.

Avant le test, pensez-vous qu’un document vide puisse satisfaire le contrat du modèle ?

  • Relancez l’application en exécutant:
python -m streamlit run app.py

Que devez-vous voir avant le test ?

La page affiche le chargeur du PPTX ainsi que le chargeur du modèle Word. Le reste de l’application apparaît après le chargement d’un CV PowerPoint lisible.

  • Chargez votre fichier CV dans le champ Fichier CV PowerPoint.
  • Chargez modele-sans-emplacements.docx dans le champ Modèle Word facultatif.

Vous voyez Génération bloquée: aucun emplacement reconnu. Ajoutez les emplacements affichés par le code dans une copie du modèle. Ce blocage est le résultat prévu.

Le modèle vide semble accepté ?

Confirmez que le fichier a été chargé dans Modèle Word facultatif. Vérifiez aussi que le bloc scan_template se trouve avant son utilisation dans l’interface.

Aidez-moi à comprendre pourquoi mon modèle DOCX sans emplacement reconnu ne déclenche pas le blocage attendu.

Le modèle ne suffit pas à déterminer ce qui peut être exporté. L’application doit maintenant comparer chaque rubrique validée avec les emplacements réellement disponibles.

  • Sous le bloc de validation du modèle, ajoutez la préparation des données exportées et exclues:
        excluded_data = []
        exported_blocks = []
        for block in reviewed_blocks:
            category = block["category"]
            if category == STRUCTURAL:
                continue
            if category == UNCLASSIFIABLE:
                excluded_data.append(
                    {**block, "exclusion_reason": "rubrique non classable"}
                )
                continue
            if template_file is not None:
                required_marker = CATEGORY_MARKERS[category]
                if required_marker not in available_markers:
                    excluded_data.append(
                        {
                            **block,
                            "exclusion_reason": (
                                f"le modèle ne contient pas {required_marker}"
                            ),
                        }
                    )
                    continue
            exported_blocks.append(block)

Comment chaque bloc est-il orienté ?

  • Les repères structurels restent hors des données exportables.
  • Les blocs Non classable rejoignent la liste des exclusions.
  • Une rubrique absente du modèle personnalisé produit une exclusion explicite.
  • Les autres blocs rejoignent exported_blocks.
  • Ajoutez l’affichage des exclusions ainsi que la confirmation requise:
        st.write("## Données exclues ou ambiguës")
        if excluded_data:
            for block in excluded_data:
                st.write(
                    f"- Diapositive {block['slide']}: {block['text']} "
                    f"({block['exclusion_reason']})"
                )
        else:
            st.write("Aucune donnée classable n’est exclue.")

        acknowledgement_needed = bool(excluded_data or unsupported_shapes)
        acknowledged = True
        if acknowledgement_needed:
            acknowledged = st.checkbox(
                "J’ai vérifié les données exclues et les éléments non convertis.",
                key=f"ack_{source_token}",
            )

        ready = bool(first_name.strip() and last_name.strip())
        ready = ready and template_ok and acknowledged

Que représente l’état ready ?

  • Les champs d’identité doivent contenir des valeurs après suppression des espaces externes.
  • Le modèle choisi doit avoir passé son contrôle.
  • Chaque exclusion ou forme non convertie doit avoir été reconnue par la personne qui valide le CV.

Avant la vérification finale, quelles différences attendez-vous entre le chemin sans modèle et le chemin avec un DOCX vide ?

  • Enregistrez app.py.
  • Relancez le contrôle final en exécutant:
python -m streamlit run app.py

Que doit confirmer la vérification ?

  • Sans modèle chargé, vous voyez la confirmation du modèle par défaut.
  • Avec le DOCX vide, vous voyez le blocage lié à l’absence d’emplacement reconnu.
  • Avec une zone de texte Word flottante, vous voyez le blocage correspondant.
  • Les blocs ambigus apparaissent sous Données exclues ou ambiguës.
  • Une confirmation supplémentaire apparaît lorsque des exclusions ou des formes non converties existent.
  • Retirez le modèle Word du champ facultatif.
  • Confirmez l’utilisation exclusive du modèle par défaut.
  • Vérifiez que les champs Prénom et Nom contiennent les valeurs attendues.
  • Contrôlez chaque ligne affichée sous Données exclues ou ambiguës.

La confirmation finale reste incomplète ?

Vérifiez les champs d’identité vides. Contrôlez ensuite la confirmation du modèle ainsi que la case liée aux données exclues.

Aidez-moi à déterminer pourquoi ready reste faux après la validation de l’identité, du modèle et des exclusions.

Beau progrès. Votre application refuse maintenant les identités incomplètes ainsi que les modèles Word qu’elle ne peut pas vérifier.

✔️ Tout est validé

Votre application contrôle le modèle par défaut ou le contrat du DOCX chargé. Les données exclues restent visibles avant toute génération.

ⓧ Je veux revérifier l’état final

  • Confirmez que app.py importe Document ainsi que BadZipFile et ZipFile.
  • Confirmez que CATEGORY_MARKERS contient un emplacement pour chaque rubrique exportable.
  • Confirmez que IDENTITY_MARKERS contient {{ nom }} ainsi que {{ prenom }}.
  • Confirmez que scan_template inspecte les paragraphes ainsi que les tableaux du document.
  • Confirmez que le parcours couvre les en-têtes ainsi que les pieds de page non liés.
  • Confirmez que les zones de texte flottantes bloquent le modèle.
  • Confirmez que les emplacements absents ou dupliqués bloquent le modèle.
  • Confirmez que les emplacements fractionnés ou inconnus bloquent le modèle.
  • Confirmez que chaque texte statique suspect exige une validation humaine.
  • Confirmez que les blocs structurels ou non classables ne rejoignent pas exported_blocks.
  • Confirmez que les rubriques absentes d’un modèle personnalisé apparaissent dans excluded_data.

Votre contrat Word est prêt. Dans l’étape suivante, vous générerez le document éditable avant de vérifier chaque occurrence issue du PowerPoint.

Generate and Audit the CV

Votre application Streamlit extrait désormais le contenu du PowerPoint. Elle vous laisse aussi valider chaque classement avant l’export.

Un fichier DOCX qui s’ouvre peut encore contenir une omission ou une duplication. Cette dernière étape construit le CV puis bloque son téléchargement tant que l’audit détecte un problème.

In this step, get ready to:
  • Construire un document Word éditable avec le modèle par défaut.
  • Remplir uniquement les emplacements reconnus d’un modèle fourni.
  • Auditer le document avant d’autoriser son téléchargement.
Construire le modèle Word par défaut

Le modèle par défaut transforme chaque rubrique validée en paragraphes éditables dans Microsoft Word. Les libellés en gras rendent la structure visible sans réécrire les textes sources.

Pourquoi un modèle aussi strict ?

Le modèle suit un ordre défini par le projet. Il évite d’inventer une colonne ou une mise en page qui ne vient pas du CV source.

Les diplômes et les références peuvent se répéter. Chaque autre rubrique conserve une place stable dans le document final.

  • Revenez à l’onglet app.py déjà ouvert dans Visual Studio Code.
  • Utilisez le second onglet ci-dessous pour repérer les fonctions absentes de votre fichier.
  • Ajoutez uniquement les lignes manquantes à app.py.

✔️ Awesome, I've got everything!

Votre fichier contient les fonctions de génération, de remplissage, d’audit et de téléchargement. Passez au lancement de l’application.

ⓧ I'd like to double check the full code

from __future__ import annotations

from collections import Counter
from hashlib import sha256
from io import BytesIO
from pathlib import Path
import re
import unicodedata
from zipfile import BadZipFile, ZipFile

import streamlit as st
from docx import Document
from pptx import Presentation


STRUCTURAL = "Repère structurel (non exporté)"
UNCLASSIFIABLE = "Non classable"

DATA_CATEGORIES = [
    "Grade",
    "Nombre d’année d’expérience",
    "Compétences > Techniques",
    "Compétences > Outils",
    "Compétences > Fonctionnelles",
    "Compétences > Conseil",
    "Certifications",
    "Langues",
    "Parcours universitaire",
    "Parcours professionnel > Référence",
    "Parcours professionnel > Contexte",
    "Parcours professionnel > Laius",
]

REVIEW_OPTIONS = DATA_CATEGORIES + [STRUCTURAL, UNCLASSIFIABLE]

CATEGORY_MARKERS = {
    "Grade": "{{ grade }}",
    "Nombre d’année d’expérience": "{{ annees_experience }}",
    "Compétences > Techniques": "{{ competences_techniques }}",
    "Compétences > Outils": "{{ competences_outils }}",
    "Compétences > Fonctionnelles": "{{ competences_fonctionnelles }}",
    "Compétences > Conseil": "{{ competences_conseil }}",
    "Certifications": "{{ certifications }}",
    "Langues": "{{ langues }}",
    "Parcours universitaire": "{{ parcours_universitaire }}",
    "Parcours professionnel > Référence": "{{ references_professionnelles }}",
    "Parcours professionnel > Contexte": "{{ contextes_professionnels }}",
    "Parcours professionnel > Laius": "{{ activites_professionnelles }}",
}

IDENTITY_MARKERS = {
    "Nom": "{{ nom }}",
    "Prénom": "{{ prenom }}",
}

ALL_MARKERS = set(CATEGORY_MARKERS.values()) | set(IDENTITY_MARKERS.values())


def normalize(text: str) -> str:
    decomposed = unicodedata.normalize("NFKD", text)
    without_accents = "".join(
        character for character in decomposed if not unicodedata.combining(character)
    )
    return " ".join(without_accents.casefold().split())


HEADING_TARGETS = {
    normalize("Nom"): STRUCTURAL,
    normalize("Prénom"): STRUCTURAL,
    normalize("Grade"): STRUCTURAL,
    normalize("Nombre d’année d’expérience"): STRUCTURAL,
    normalize("Compétences"): STRUCTURAL,
    normalize("Techniques"): STRUCTURAL,
    normalize("Outils"): STRUCTURAL,
    normalize("Fonctionnelles"): STRUCTURAL,
    normalize("Conseil"): STRUCTURAL,
    normalize("Certifications"): STRUCTURAL,
    normalize("Langues"): STRUCTURAL,
    normalize("Parcours universitaire"): STRUCTURAL,
    normalize("Diplômes"): STRUCTURAL,
    normalize("Parcours professionnel"): STRUCTURAL,
    normalize("Expériences professionnelles"): STRUCTURAL,
    normalize("Références"): STRUCTURAL,
    normalize("Contexte"): STRUCTURAL,
    normalize("Activités réalisées"): STRUCTURAL,
}

RULES = {
    "Grade": ("grade", "consultant", "manager", "architecte", "senior", "junior"),
    "Nombre d’année d’expérience": (
        "année d'expérience",
        "années d'expérience",
        "ans d'expérience",
        "expérience totale",
    ),
    "Compétences > Techniques": (
        "python",
        "java",
        "javascript",
        "typescript",
        "sql",
        "cloud",
        "api",
        "architecture",
    ),
    "Compétences > Outils": (
        "outils",
        "git",
        "docker",
        "jira",
        "visual studio code",
        "power bi",
    ),
    "Compétences > Fonctionnelles": (
        "fonctionnel",
        "fonctionnelle",
        "métier",
        "processus",
        "besoin",
    ),
    "Compétences > Conseil": (
        "conseil",
        "accompagnement",
        "atelier",
        "gouvernance",
        "audit",
    ),
    "Certifications": ("certification", "certifié", "certifiée"),
    "Langues": ("langue", "français", "anglais", "espagnol", "allemand"),
    "Parcours universitaire": (
        "diplôme",
        "université",
        "école",
        "master",
        "licence",
        "doctorat",
        "baccalauréat",
    ),
    "Parcours professionnel > Référence": (
        "référence",
        "expérience",
        "mission",
        "projet",
        "client",
    ),
    "Parcours professionnel > Contexte": (
        "contexte",
        "organisation",
        "environnement technique",
        "environnement métier",
    ),
    "Parcours professionnel > Laius": (
        "activité",
        "réalisation",
        "responsabilité",
        "tâche",
    ),
}

ALLOWED_STATIC_HEADINGS = {
    normalize("Nom"),
    normalize("Prénom"),
    normalize("Grade"),
    normalize("Nombre d’année d’expérience"),
    normalize("Compétences"),
    normalize("Techniques"),
    normalize("Outils"),
    normalize("Fonctionnelles"),
    normalize("Conseil"),
    normalize("Certifications"),
    normalize("Langues"),
    normalize("Parcours universitaire"),
    normalize("Parcours professionnel"),
    normalize("Références professionnelles"),
    normalize("Contextes professionnels"),
    normalize("Activités professionnelles"),
}


def parse_names_from_filename(filename: str) -> tuple[str, str]:
    stem = Path(filename).stem
    tokens = [
        token
        for token in re.split(r"[_\s]+", stem)
        if token and normalize(token) not in {"cv", "resume"}
    ]
    if not tokens:
        return "", ""
    if len(tokens) == 1:
        return tokens[0], ""
    return tokens[0], " ".join(tokens[1:])


def append_block(
    blocks: list[dict],
    slide_number: int,
    source: str,
    text: str,
) -> None:
    visual_text = text.replace("\v", "\n")
    if not visual_text.strip():
        return
    blocks.append(
        {
            "id": len(blocks),
            "slide": slide_number,
            "source": source,
            "text": visual_text,
        }
    )


def extract_shapes(shapes, slide_number: int, blocks: list[dict], unsupported: list[str]) -> None:
    ordered_shapes = sorted(
        shapes,
        key=lambda shape: (getattr(shape, "top", 0), getattr(shape, "left", 0)),
    )

    for shape in ordered_shapes:
        shape_name = getattr(shape, "name", "Élément")

        if hasattr(shape, "shapes"):
            extract_shapes(shape.shapes, slide_number, blocks, unsupported)
            continue

        extracted = False

        if getattr(shape, "has_table", False):
            extracted = True
            for row_index, row in enumerate(shape.table.rows, start=1):
                for column_index, cell in enumerate(row.cells, start=1):
                    if cell.is_spanned:
                        continue
                    for paragraph_number, paragraph in enumerate(
                        cell.text_frame.paragraphs, start=1
                    ):
                        append_block(
                            blocks,
                            slide_number,
                            (
                                f"Tableau {shape_name}, ligne {row_index}, "
                                f"colonne {column_index}, paragraphe {paragraph_number}"
                            ),
                            paragraph.text,
                        )

        if getattr(shape, "has_text_frame", False):
            extracted = True
            for paragraph_number, paragraph in enumerate(
                shape.text_frame.paragraphs, start=1
            ):
                append_block(
                    blocks,
                    slide_number,
                    f"Forme {shape_name}, paragraphe {paragraph_number}",
                    paragraph.text,
                )

        if not extracted:
            unsupported.append(f"Diapositive {slide_number}: {shape_name}")


def extract_pptx(pptx_bytes: bytes) -> tuple[list[dict], list[str]]:
    presentation = Presentation(BytesIO(pptx_bytes))
    blocks: list[dict] = []
    unsupported: list[str] = []

    for slide_number, slide in enumerate(presentation.slides, start=1):
        extract_shapes(slide.shapes, slide_number, blocks, unsupported)

    return blocks, unsupported


def classify_text(text: str, first_name: str, last_name: str) -> tuple[str, int, str]:
    candidate = normalize(text)
    identity_values = {
        normalize(first_name),
        normalize(last_name),
        normalize(f"{first_name} {last_name}"),
        normalize(f"{last_name} {first_name}"),
    } - {""}

    if candidate in identity_values:
        return STRUCTURAL, 100, "nom ou prénom déjà géré par les champs d’identité"

    if candidate in HEADING_TARGETS:
        return STRUCTURAL, 100, "titre de rubrique reconnu"

    scores: list[tuple[float, str, str]] = []
    for category, keywords in RULES.items():
        best_score = 0.0
        best_keyword = ""
        for keyword in keywords:
            normalized_keyword = normalize(keyword)
            if candidate == normalized_keyword:
                score = 0.95
            elif candidate.startswith(normalized_keyword + " "):
                score = 0.85
            elif normalized_keyword in candidate:
                score = 0.65
            else:
                score = 0.0
            if score > best_score:
                best_score = score
                best_keyword = keyword
        if best_score:
            scores.append((best_score, category, best_keyword))

    if re.search(r"\b(?:19|20)\d{2}\b", candidate):
        scores.append((0.60, "Parcours professionnel > Référence", "année détectée"))

    if not scores:
        return UNCLASSIFIABLE, 0, "aucune règle correspondante"

    top_score = max(score for score, _, _ in scores)
    winners = [entry for entry in scores if entry[0] == top_score]
    winner_categories = {category for _, category, _ in winners}

    if len(winner_categories) != 1:
        return UNCLASSIFIABLE, int(top_score * 100), "plusieurs rubriques à égalité"

    _, category, keyword = winners[0]
    return category, int(top_score * 100), f"motif détecté: {keyword}"


def classify_blocks(
    blocks: list[dict], first_name: str, last_name: str
) -> list[dict]:
    classified = []
    for block in blocks:
        category, confidence, reason = classify_text(
            block["text"], first_name, last_name
        )
        classified.append(
            {
                **block,
                "suggested_category": category,
                "category": category,
                "confidence": confidence,
                "reason": reason,
            }
        )
    return classified


def texts_for_category(blocks: list[dict], category: str) -> list[str]:
    return [block["text"] for block in blocks if block["category"] == category]


def add_bold_heading(document, text: str) -> None:
    paragraph = document.add_paragraph()
    paragraph.add_run(text).bold = True


def add_label_with_values(
    document, label: str, values: list[str], inline: bool = False
) -> None:
    paragraph = document.add_paragraph()
    paragraph.add_run(label).bold = True
    if values:
        separator = " " if inline else "\n"
        paragraph.add_run(separator + "\n".join(values))


def professional_groups(blocks: list[dict]) -> list[dict[str, list[str]]]:
    professional_categories = {
        "Parcours professionnel > Référence": "references",
        "Parcours professionnel > Contexte": "contexts",
        "Parcours professionnel > Laius": "activities",
    }
    groups: list[dict[str, list[str]]] = []
    current = {"references": [], "contexts": [], "activities": []}

    for block in blocks:
        category = block["category"]
        if category not in professional_categories:
            continue
        bucket = professional_categories[category]
        if bucket == "references" and any(current.values()):
            groups.append(current)
            current = {"references": [], "contexts": [], "activities": []}
        current[bucket].append(block["text"])

    if any(current.values()):
        groups.append(current)
    return groups


def build_default_docx(
    blocks: list[dict], first_name: str, last_name: str
) -> bytes:
    document = Document()

    add_label_with_values(document, "Nom", [last_name], inline=True)
    add_label_with_values(document, "Prénom", [first_name], inline=True)
    add_label_with_values(document, "Grade", texts_for_category(blocks, "Grade"))
    add_label_with_values(
        document,
        "Nombre d’année d’expérience",
        texts_for_category(blocks, "Nombre d’année d’expérience"),
    )

    add_bold_heading(document, "Compétences")
    add_label_with_values(
        document,
        "Techniques",
        texts_for_category(blocks, "Compétences > Techniques"),
    )
    add_label_with_values(
        document,
        "Outils",
        texts_for_category(blocks, "Compétences > Outils"),
    )
    add_label_with_values(
        document,
        "Fonctionnelles",
        texts_for_category(blocks, "Compétences > Fonctionnelles"),
    )
    add_label_with_values(
        document,
        "Conseil",
        texts_for_category(blocks, "Compétences > Conseil"),
    )

    add_label_with_values(
        document, "Certifications", texts_for_category(blocks, "Certifications")
    )
    add_label_with_values(document, "Langues", texts_for_category(blocks, "Langues"))

    add_bold_heading(document, "Parcours universitaire")
    for index, diploma in enumerate(
        texts_for_category(blocks, "Parcours universitaire"), start=1
    ):
        add_bold_heading(document, f"Diplôme {index}")
        document.add_paragraph(diploma)

    add_bold_heading(document, "Parcours professionnel")
    for index, group in enumerate(professional_groups(blocks), start=1):
        add_bold_heading(document, f"Référence {index} avec année et mois")
        for text in group["references"]:
            document.add_paragraph(text)

        if group["contexts"]:
            add_bold_heading(
                document,
                f"Contexte de la référence {index} : organisation, technique et métier",
            )
            for text in group["contexts"]:
                document.add_paragraph(text)

        if group["activities"]:
            add_bold_heading(
                document, f"Laius de la référence {index} : activité réalisée"
            )
            for text in group["activities"]:
                document.add_paragraph(text)

    output = BytesIO()
    document.save(output)
    return output.getvalue()


def iter_table_paragraphs(table):
    for row in table.rows:
        for cell in row.cells:
            for paragraph in cell.paragraphs:
                yield paragraph
            for nested_table in cell.tables:
                yield from iter_table_paragraphs(nested_table)


def iter_container_paragraphs(container):
    for paragraph in container.paragraphs:
        yield paragraph
    for table in container.tables:
        yield from iter_table_paragraphs(table)


def iter_all_paragraphs(document):
    yield from iter_container_paragraphs(document)

    container_names = (
        "header",
        "even_page_header",
        "first_page_header",
        "footer",
        "even_page_footer",
        "first_page_footer",
    )
    for section in document.sections:
        for container_name in container_names:
            container = getattr(section, container_name)
            if container.is_linked_to_previous:
                continue
            yield from iter_container_paragraphs(container)


def contains_textbox(docx_bytes: bytes) -> bool:
    try:
        with ZipFile(BytesIO(docx_bytes)) as archive:
            for name in archive.namelist():
                if name.startswith("word/") and name.endswith(".xml"):
                    xml = archive.read(name)
                    if b":txbx" in xml or b":textbox" in xml:
                        return True
    except BadZipFile:
        return True
    return False


def clean_static_label(text: str) -> str:
    return normalize(text).strip(" :;.-")


def scan_template(template_bytes: bytes) -> dict:
    document = Document(BytesIO(template_bytes))
    paragraphs = list(iter_all_paragraphs(document))
    paragraph_texts = [paragraph.text for paragraph in paragraphs if paragraph.text]
    combined = "\n".join(paragraph_texts)

    marker_counts = {
        marker: combined.count(marker) for marker in ALL_MARKERS if marker in combined
    }
    found_markers = set(marker_counts)

    split_markers = set()
    for paragraph in paragraphs:
        for marker in found_markers:
            if marker in paragraph.text and not any(
                marker in run.text for run in paragraph.runs
            ):
                split_markers.add(marker)

    all_tags = set(re.findall(r"\{\{.*?\}\}", combined))
    unknown_tags = all_tags - ALL_MARKERS

    suspicious = set()
    for text in paragraph_texts:
        residual = re.sub(r"\{\{.*?\}\}", "", text).strip()
        if not residual:
            continue
        if not re.search(r"[A-Za-zÀ-ÿ0-9]", residual):
            continue
        if clean_static_label(residual) not in ALLOWED_STATIC_HEADINGS:
            suspicious.add(residual)

    return {
        "found_markers": found_markers,
        "duplicate_markers": {
            marker: count for marker, count in marker_counts.items() if count != 1
        },
        "split_markers": split_markers,
        "unknown_tags": unknown_tags,
        "suspicious_text": sorted(suspicious),
        "has_textbox": contains_textbox(template_bytes),
    }


def build_template_values(
    blocks: list[dict], first_name: str, last_name: str
) -> dict[str, str]:
    values = {marker: "" for marker in ALL_MARKERS}
    values[IDENTITY_MARKERS["Nom"]] = last_name
    values[IDENTITY_MARKERS["Prénom"]] = first_name
    for category, marker in CATEGORY_MARKERS.items():
        values[marker] = "\n".join(texts_for_category(blocks, category))
    return values


def fill_template(template_bytes: bytes, values: dict[str, str]) -> bytes:
    document = Document(BytesIO(template_bytes))
    for paragraph in iter_all_paragraphs(document):
        for run in paragraph.runs:
            updated = run.text
            for marker, value in values.items():
                if marker in updated:
                    updated = updated.replace(marker, value)
            if updated != run.text:
                run.text = updated

    output = BytesIO()
    document.save(output)
    return output.getvalue()


def extract_docx_text(docx_bytes: bytes) -> str:
    document = Document(BytesIO(docx_bytes))
    return "\n".join(
        paragraph.text for paragraph in iter_all_paragraphs(document)
    )


def audit_docx(
    docx_bytes: bytes,
    exported_blocks: list[dict],
    first_name: str,
    last_name: str,
) -> list[str]:
    final_text = extract_docx_text(docx_bytes)
    issues = []

    expected = Counter(
        block["text"] for block in exported_blocks if block["text"].strip()
    )
    occupied = [False] * len(final_text)

    for source_text, expected_count in sorted(
        expected.items(), key=lambda item: len(item[0]), reverse=True
    ):
        positions = []
        start = 0
        while True:
            position = final_text.find(source_text, start)
            if position == -1:
                break
            end = position + len(source_text)
            if not any(occupied[position:end]):
                positions.append((position, end))
            start = position + 1

        if len(positions) != expected_count:
            issues.append(
                f"Occurrence incorrecte pour {source_text!r}: "
                f"attendu {expected_count}, trouvé {len(positions)}"
            )

        for position, end in positions[:expected_count]:
            for index in range(position, end):
                occupied[index] = True

    if first_name not in final_text:
        issues.append("Le prénom est absent du document final.")
    if last_name not in final_text:
        issues.append("Le nom est absent du document final.")

    remaining_markers = [marker for marker in ALL_MARKERS if marker in final_text]
    if remaining_markers:
        issues.append(
            "Emplacements non remplacés: " + ", ".join(sorted(remaining_markers))
        )

    return issues


def output_filename(first_name: str, last_name: str) -> str:
    safe_first_name = "_".join(first_name.strip().split())
    safe_last_name = "_".join(last_name.strip().split())
    return f"CV_{safe_first_name}_{safe_last_name}.docx"


st.write("# Générateur local de CV Word depuis PowerPoint")
st.write(
    "Le texte source reste inchangé. Les règles proposent un classement, "
    "mais l’utilisateur valide chaque décision avant génération."
)

pptx_file = st.file_uploader("Fichier CV PowerPoint", type="pptx")
template_file = st.file_uploader("Modèle Word facultatif", type="docx")

if pptx_file is None:
    st.write("Chargez un fichier PPTX pour commencer.")
else:
    pptx_bytes = pptx_file.getvalue()
    source_token = sha256(pptx_bytes).hexdigest()[:12]

    try:
        raw_blocks, unsupported_shapes = extract_pptx(pptx_bytes)
    except Exception as error:
        st.write(f"Erreur de lecture du PPTX: {error}")
        raw_blocks = []
        unsupported_shapes = []

    if not raw_blocks:
        st.write("Aucun paragraphe ni cellule textuelle exploitable n’a été trouvé.")
    else:
        guessed_first_name, guessed_last_name = parse_names_from_filename(
            pptx_file.name
        )
        first_name = st.text_input(
            "Prénom",
            value=guessed_first_name,
            required=True,
            key=f"first_{source_token}",
        )
        last_name = st.text_input(
            "Nom",
            value=guessed_last_name,
            required=True,
            key=f"last_{source_token}",
        )

        classified_blocks = classify_blocks(raw_blocks, first_name, last_name)
        reviewed_blocks = []

        st.write("## Validation du classement")
        for block in classified_blocks:
            st.write(
                f"Bloc {block['id'] + 1} | Diapositive {block['slide']} | "
                f"{block['source']}"
            )
            st.text_area(
                f"Texte source du bloc {block['id'] + 1}",
                value=block["text"],
                disabled=True,
                height="content",
                key=f"source_{source_token}_{block['id']}",
            )
            st.write(
                f"Suggestion: {block['suggested_category']} | "
                f"Confiance: {block['confidence']} % | {block['reason']}"
            )
            selected_category = st.selectbox(
                f"Rubrique validée pour le bloc {block['id'] + 1}",
                REVIEW_OPTIONS,
                index=REVIEW_OPTIONS.index(block["suggested_category"]),
                key=f"category_{source_token}_{block['id']}",
            )
            reviewed_blocks.append({**block, "category": selected_category})

        if unsupported_shapes:
            st.write("## Éléments PPTX non convertis")
            for item in unsupported_shapes:
                st.write(f"- {item}")

        template_ok = False
        available_markers = set(ALL_MARKERS)

        if template_file is None:
            st.write("## Modèle par défaut")
            template_ok = st.checkbox(
                "Je confirme l’utilisation exclusive du modèle par défaut défini.",
                key=f"default_{source_token}",
            )
        else:
            st.write("## Contrôle du modèle Word")
            template_bytes = template_file.getvalue()
            template_token = sha256(template_bytes).hexdigest()[:12]

            try:
                template_report = scan_template(template_bytes)
                available_markers = template_report["found_markers"]

                if template_report["has_textbox"]:
                    st.write(
                        "Génération bloquée: le modèle contient une zone de texte "
                        "Word flottante non prise en charge."
                    )

                missing_identity = set(IDENTITY_MARKERS.values()) - available_markers
                if missing_identity:
                    st.write(
                        "Génération bloquée: emplacements d’identité manquants: "
                        + ", ".join(sorted(missing_identity))
                    )

                if not available_markers:
                    st.write(
                        "Génération bloquée: aucun emplacement reconnu. Ajoutez les "
                        "emplacements affichés par le code dans une copie du modèle."
                    )

                if template_report["duplicate_markers"]:
                    st.write(
                        "Génération bloquée: emplacements dupliqués: "
                        + ", ".join(
                            sorted(template_report["duplicate_markers"].keys())
                        )
                    )

                if template_report["split_markers"]:
                    st.write(
                        "Génération bloquée: emplacements fractionnés entre plusieurs "
                        "runs Word: "
                        + ", ".join(sorted(template_report["split_markers"]))
                    )

                if template_report["unknown_tags"]:
                    st.write(
                        "Génération bloquée: emplacements inconnus: "
                        + ", ".join(sorted(template_report["unknown_tags"]))
                    )

                static_confirmations = []
                if template_report["suspicious_text"]:
                    st.write(
                        "Confirmez que chaque texte suivant est structurel et non une "
                        "donnée d’exemple."
                    )
                    for index, text in enumerate(
                        template_report["suspicious_text"], start=1
                    ):
                        st.text_area(
                            f"Texte statique potentiel {index}",
                            value=text,
                            disabled=True,
                            height="content",
                            key=f"static_text_{template_token}_{index}",
                        )
                        static_confirmations.append(
                            st.checkbox(
                                "Ce texte appartient au modèle et doit être conservé.",
                                key=f"static_ok_{template_token}_{index}",
                            )
                        )

                static_ok = all(static_confirmations)
                template_ok = (
                    bool(available_markers)
                    and not template_report["has_textbox"]
                    and not missing_identity
                    and not template_report["duplicate_markers"]
                    and not template_report["split_markers"]
                    and not template_report["unknown_tags"]
                    and static_ok
                )
            except Exception as error:
                st.write(f"Modèle Word illisible ou incompatible: {error}")
                template_ok = False
                available_markers = set()

        excluded_data = []
        exported_blocks = []
        for block in reviewed_blocks:
            category = block["category"]
            if category == STRUCTURAL:
                continue
            if category == UNCLASSIFIABLE:
                excluded_data.append(
                    {**block, "exclusion_reason": "rubrique non classable"}
                )
                continue
            if template_file is not None:
                required_marker = CATEGORY_MARKERS[category]
                if required_marker not in available_markers:
                    excluded_data.append(
                        {
                            **block,
                            "exclusion_reason": (
                                f"le modèle ne contient pas {required_marker}"
                            ),
                        }
                    )
                    continue
            exported_blocks.append(block)

        st.write("## Données exclues ou ambiguës")
        if excluded_data:
            for block in excluded_data:
                st.write(
                    f"- Diapositive {block['slide']}: {block['text']} "
                    f"({block['exclusion_reason']})"
                )
        else:
            st.write("Aucune donnée classable n’est exclue.")

        acknowledgement_needed = bool(excluded_data or unsupported_shapes)
        acknowledged = True
        if acknowledgement_needed:
            acknowledged = st.checkbox(
                "J’ai vérifié les données exclues et les éléments non convertis.",
                key=f"ack_{source_token}",
            )

        ready = bool(first_name.strip() and last_name.strip())
        ready = ready and template_ok and acknowledged

        if st.button(
            "Générer et contrôler le CV",
            disabled=not ready,
            type="primary",
        ):
            try:
                if template_file is None:
                    docx_bytes = build_default_docx(
                        reviewed_blocks, first_name.strip(), last_name.strip()
                    )
                else:
                    values = build_template_values(
                        reviewed_blocks, first_name.strip(), last_name.strip()
                    )
                    docx_bytes = fill_template(template_file.getvalue(), values)

                audit_issues = audit_docx(
                    docx_bytes,
                    exported_blocks,
                    first_name.strip(),
                    last_name.strip(),
                )

                if audit_issues:
                    st.write("Le téléchargement est bloqué par le contrôle final:")
                    for issue in audit_issues:
                        st.write(f"- {issue}")
                else:
                    final_name = output_filename(
                        first_name.strip(), last_name.strip()
                    )
                    st.write(
                        "Le contrôle d’exhaustivité et de conformité au modèle "
                        "a été effectué avec succès."
                    )
                    st.download_button(
                        "Télécharger le CV Word",
                        data=docx_bytes,
                        file_name=final_name,
                        mime=(
                            "application/vnd.openxmlformats-officedocument."
                            "wordprocessingml.document"
                        ),
                        on_click="ignore",
                        type="primary",
                    )
            except Exception as error:
                st.write(f"La génération a échoué: {error}")

Ce que le fichier ajoute

  • Les fonctions de modèle par défaut créent les rubriques dans l’ordre attendu.
  • Les fonctions de modèle fourni remplacent seulement les emplacements reconnus.
  • La fonction audit_docx() compare le document final aux blocs exportés.
  • Le workflow affiche le téléchargement uniquement après un audit sans erreur.
  • Enregistrez app.py.
  • Revenez au terminal PowerShell déjà ouvert dans Visual Studio Code.
  • Lancez l’application complète en exécutant cette commande:
python -m streamlit run app.py

Ce que lance cette commande

Streamlit exécute maintenant le générateur complet depuis app.py. Le terminal reste occupé pendant que l’application locale fonctionne.

  • Ouvrez l’adresse locale affichée dans le terminal si le navigateur ne s’ouvre pas automatiquement.
  • Confirmez que la page affiche le titre Générateur local de CV Word depuis PowerPoint.
  • Confirmez que les champs Fichier CV PowerPoint et Modèle Word facultatif sont visibles.

Le générateur complet est maintenant chargé dans le navigateur. La prochaine vérification porte sur le chemin réservé aux modèles Word fournis.

L’application ne démarre pas ?

Vérifiez que le terminal affiche encore le préfixe .venv. Vérifiez aussi que app.py est enregistré dans le dossier du projet.

Une erreur de syntaxe indique souvent une ligne manquante pendant la comparaison du fichier complet.

Aidez-moi à diagnostiquer le démarrage de mon application Streamlit locale sans modifier les versions de mon fichier requirements.txt.

Préserver les emplacements d’un modèle fourni

Un modèle compatible possède ses propres styles. La fonction fill_template() remplace chaque emplacement dans son run existant pour conserver le format de cette zone.

Comment les données sont préparées

  • La fonction build_template_values() associe le prénom et le nom à leurs emplacements d’identité.
  • Chaque rubrique reçoit les textes validés dans leur ordre source.
  • Une rubrique absente du modèle reste exclue de l’export.
  • La fonction fill_template() remplace seulement les marqueurs présents dans un run.
  • Dans Visual Studio Code, sélectionnez Edit dans la barre de menus.
  • Sélectionnez Find.
  • Recherchez def build_template_values.
  • Confirmez que les emplacements d’identité reçoivent last_name et first_name.
  • Recherchez def fill_template.
  • Confirmez que la substitution parcourt les runs retournés par iter_all_paragraphs().

Cette lecture confirme que l’application conserve le format du run qui contenait l’emplacement. Elle ne reconstruit pas la mise en page du modèle.

Auditer et télécharger le CV

L’audit relit le document généré avant de créer le bouton de téléchargement. Il vérifie les occurrences des blocs exportés puis contrôle l’identité et les emplacements restants.

Comment l’audit détecte les doublons

Un Counter enregistre le nombre attendu pour chaque texte source. Les positions déjà utilisées empêchent un texte long et l’un de ses sous-textes de compter le même passage deux fois.

Toute différence rejoint la liste issues. Le téléchargement reste alors bloqué.

  • Revenez à l’application ouverte dans le navigateur.
  • Chargez votre CV de test avec le champ Fichier CV PowerPoint.
  • Corrigez le champ Prénom si la valeur proposée ne correspond pas au fichier.
  • Corrigez le champ Nom si la valeur proposée ne correspond pas au fichier.
  • Validez la rubrique de chaque bloc avec la liste correspondante.
  • Cochez Je confirme l’utilisation exclusive du modèle par défaut défini.
  • Cochez J’ai vérifié les données exclues et les éléments non convertis. si cette confirmation est affichée.

Avant de lancer le contrôle, pensez-vous que chaque bloc exportable apparaîtra exactement une fois dans le document final ?

  • Cliquez sur Générer et contrôler le CV.

Vous verrez le message Le contrôle d’exhaustivité et de conformité au modèle a été effectué avec succès. Le bouton de téléchargement apparaît juste après ce résultat.

Le téléchargement reste bloqué ?

Lisez chaque ligne affichée sous Le téléchargement est bloqué par le contrôle final: Une occurrence incorrecte indique un texte absent ou présent trop souvent.

Un problème d’identité signifie que le prénom ou le nom validé ne se trouve pas dans le document généré. Un emplacement restant indique qu’un marqueur reconnu n’a pas été remplacé.

Aidez-moi à comprendre l’échec de l’audit DOCX sans supprimer ni reformuler les textes sources.

  • Cliquez sur Télécharger le CV Word.
  • Ouvrez le fichier téléchargé depuis la liste des téléchargements de votre navigateur.
  • Confirmez que Microsoft Word affiche le fichier CV_Prénom_Nom.docx avec le prénom et le nom de votre test.
  • Vérifiez que les rubriques du modèle par défaut apparaissent en gras.
  • Comparez les blocs exportables au contenu affiché dans Microsoft Word.

C’est le contrôle décisif terminé. Votre générateur livre maintenant un CV Word éditable uniquement après avoir prouvé la présence unique de chaque texte exporté.

Secret mission

Préparer un modèle Word personnalisé

Transformez une copie d’un modèle Word existant en modèle compatible avec votre générateur. Préservez sa mise en page tout en remplaçant ses données d’exemple par des marqueurs contrôlés.

Clean Up Your Resources

Clean Up Your Resources

Choisissez de conserver vos ressources, de les mettre en pause pour y revenir ou de les supprimer entièrement. Ce projet s’exécute entièrement en local sans coût récurrent.

Resources you used:

  • Le fichier app.py de l’application locale créée avec Streamlit.
  • L’environnement virtuel Python stocké dans .venv.
  • Les dépendances épinglées déclarées dans requirements.txt.
  • Le modèle custom_cv_template.docx préparé dans Microsoft Word.
  • Le document audité CV_Prénom_Nom.docx généré depuis le modèle personnalisé.

Keep everything running

Aucune action n’est nécessaire. Choisissez cette option si vous souhaitez continuer à tester ou modifier le générateur.

  • Conservez app.py pour poursuivre le développement de l’application.
  • Conservez requirements.txt pour retrouver les versions des dépendances.
  • Conservez .venv pour réutiliser l’environnement isolé.
  • Conservez custom_cv_template.docx pour générer d’autres CV avec la même mise en page.
  • Conservez CV_Prénom_Nom.docx comme résultat éditable de votre test.

Pause - I'll come back to this later

Arrêtez les processus locaux pour libérer les ressources de votre ordinateur. Vos fichiers restent disponibles pour une prochaine session.

  • Cliquez sur l’icône de corbeille dans le terminal intégré de Visual Studio Code pour arrêter tout processus Streamlit encore actif.
  • Fermez l’onglet du navigateur qui affichait l’application locale.
  • Fermez Visual Studio Code.
  • Conservez tous les fichiers du projet sans les modifier.

Delete - I don't want to use this again

Retirez les ressources créées pendant le projet si vous souhaitez repartir de zéro. La suppression touche uniquement vos copies locales.

  • Conservez votre fichier source .pptx si vous souhaitez garder le CV PowerPoint d’origine.
  • Fermez custom_cv_template.docx dans Microsoft Word.
  • Fermez CV_Prénom_Nom.docx dans Microsoft Word.
  • Supprimez app.py depuis l’arborescence du projet dans Visual Studio Code.
  • Supprimez requirements.txt depuis l’arborescence du projet dans Visual Studio Code.
  • Supprimez le dossier .venv depuis l’arborescence du projet dans Visual Studio Code.
  • Supprimez custom_cv_template.docx depuis son emplacement actuel avec l’Explorateur de fichiers Windows.
  • Supprimez CV_Prénom_Nom.docx depuis son emplacement actuel avec l’Explorateur de fichiers Windows.

Nice Work!

Nice Work!

Bravo ! Vous avez construit un générateur local de CV avec Streamlit qui transforme un fichier PowerPoint en document Microsoft Word éditable. Votre application préserve chaque texte source avant de contrôler le document final.

What you learned:

  • Réalisé une extraction structurée des paragraphes et cellules visibles. Conservé la provenance de chaque bloc selon son numéro de diapositive.
  • Créé un classement déterministe avec des scores et des motifs explicables. Ajouté une validation humaine qui protège le texte source.
  • Généré un CV Word audité nommé CV_Prénom_Nom.docx. Bloqué le téléchargement lorsque le contrôle détecte une omission ou une duplication.
  • Secret Mission: Préparé custom_cv_template.docx avec des emplacements explicites placés dans des runs uniques. Conservé sa mise en page après la génération du CV personnalisé.

Ready to quiz yourself?