diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..1c4b040 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,10 @@ +{ + "permissions": { + "allow": [ + "Bash(pip install:*)", + "Bash(pip3 install:*)", + "Bash(python3 -m pip install python-docx)", + "Bash(python3:*)" + ] + } +} diff --git a/.claude/skills/formation-docx-replacer/SKILL.md b/.claude/skills/formation-docx-replacer/SKILL.md new file mode 100644 index 0000000..19fb162 --- /dev/null +++ b/.claude/skills/formation-docx-replacer/SKILL.md @@ -0,0 +1,54 @@ +--- +name: formation-docx-replacer +description: Use when the user asks to create a new formation dossier, replace text in docx files, or apply a YAML config to modify formation documents. Triggers on keywords like formation, dossier, remplacements, docx, yaml config, nouvelle formation. +--- + +# Formation DOCX Replacer + +## Overview + +Prepare un dossier de formation a partir du template et guide l'utilisateur pour configurer et lancer les remplacements dans les .docx. + +## When to Use + +- L'utilisateur veut creer une nouvelle formation +- L'utilisateur mentionne un nouveau dossier de formation +- L'utilisateur veut dupliquer le template pour un nouveau client/formation + +## Workflow + +1. **Demander le nom de la formation** a l'utilisateur (ex: "Excel Avance Dupont Avril 2026") +2. **Creer le dossier** dans `formations/` en slugifiant le nom (ex: `formations/excel_avance_dupont_avril_2026/`) +3. **Copier le template YAML** : `template/formation_template.yaml` -> `formations//formation.yaml` +4. **Indiquer a l'utilisateur** : + - Le chemin du fichier YAML a editer + - Qu'il doit remplir les paires ancien/nouveau avec ses infos + - La commande exacte a lancer une fois le YAML pret : + ``` + python3 replace_docx.py formations//formation.yaml + ``` + +Ne PAS essayer de remplir le YAML a la place de l'utilisateur. Il connait ses infos. + +## Format YAML + +```yaml +dossier_source: template +remplacements: + - ancien: "texte a chercher" + nouveau: "texte de remplacement" + - ancien: "autre texte" + nouveau: "nouveau texte" + fichiers: ["seul_ce_fichier.docx"] # optionnel, par defaut tous +``` + +## Ce que le script gere + +- Paragraphes, tableaux (imbriques), en-tetes, pieds de page +- Texte decoupe sur plusieurs runs Word +- Preservation du formatage (police, taille, couleur, gras) + +## Common Mistakes + +- **Texte introuvable** : Word fragmente parfois le texte en plusieurs runs. Verifier avec python-docx +- **Accents** : YAML en UTF-8 diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..ab1f416 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,10 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Ignored default folder with query files +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml +# Editor-based HTTP Client requests +/httpRequests/ diff --git a/.idea/material_theme_project_new.xml b/.idea/material_theme_project_new.xml new file mode 100644 index 0000000..99ddff2 --- /dev/null +++ b/.idea/material_theme_project_new.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..330186c --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/php.xml b/.idea/php.xml new file mode 100644 index 0000000..000a3ea --- /dev/null +++ b/.idea/php.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/qualiopi.iml b/.idea/qualiopi.iml new file mode 100644 index 0000000..bf4c9d3 --- /dev/null +++ b/.idea/qualiopi.iml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..9661ac7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..57408c1 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,53 @@ +# CLAUDE.md - Qualiopi + +## Contexte projet + +Ce depot contient les documents de formation de la SAS MALIO (organisme de formation certifie Qualiopi). L'objectif principal est d'automatiser la creation de nouveaux dossiers de formation a partir de templates existants. + +## Structure + +``` +qualiopi/ +├── replace_docx.py # Script principal +├── template/ # Documents modeles (ne jamais modifier directement) +│ ├── *.docx # Documents template +│ └── formation_template.yaml # Modele de config +└── formations/ # Un sous-dossier par formation generee + └── / + ├── formation.yaml # Config avec les remplacements + ├── rapport.md # Rapport des modifications + └── *.docx # Documents generes +``` + +## Outils disponibles + +### replace_docx.py + +Script Python de remplacement de texte dans des fichiers `.docx`. + +- **Commande** : `python3 replace_docx.py ` +- **Dependances** : python-docx, pyyaml +- **Comportement** : copie les .docx du template dans le dossier du YAML, applique les remplacements et genere un `rapport.md` + +## Workflow pour creer une formation + +Via le skill : `/formation-docx-replacer ` (cree le dossier et copie le YAML) + +Ou manuellement : +1. `mkdir formations/` +2. `cp template/formation_template.yaml formations//formation.yaml` +3. Editer le YAML avec les paires ancien/nouveau +4. `python3 replace_docx.py formations//formation.yaml` +5. Verifier `rapport.md` dans le dossier (signaler les remplacements non trouves) + +## Regles importantes + +- **Ne jamais modifier le dossier template/** : le script copie les .docx dans le dossier de la formation +- **Chemins relatifs** : dans le YAML, les chemins sont relatifs a la racine du projet +- **Encodage** : fichiers YAML en UTF-8 +- **Verifier le rapport** : apres chaque execution, signaler les remplacements non trouves a l'utilisateur +- **Formatage** : le script preserve les polices, tailles, couleurs et styles + +## Skill associe + +Le skill `formation-docx-replacer` dans `.claude/skills/` gere l'utilisation automatique du script. diff --git a/README.md b/README.md new file mode 100644 index 0000000..608ecdb --- /dev/null +++ b/README.md @@ -0,0 +1,113 @@ +# Qualiopi - Generateur de dossiers de formation + +Outil d'automatisation pour creer des dossiers de formation a partir d'un template existant. Remplace en masse les informations (client, dates, formateur, etc.) dans tous les fichiers `.docx` d'un dossier, en preservant le formatage d'origine. + +## Prerequis + +- Python 3.10+ +- python-docx (`pip3 install python-docx`) +- PyYAML (`pip3 install pyyaml`) + +## Structure du projet + +``` +qualiopi/ +├── replace_docx.py # Script de remplacement +├── template/ # Dossier template (ne pas modifier) +│ ├── CONDITIONS_GENERALES_DE_VENTE.docx +│ ├── formation_template.yaml # Modele de config a copier +│ └── ... # Autres documents template +└── formations/ # Dossiers generes par formation + └── excel_dupont_avril2026/ + ├── formation.yaml # Config de la formation + ├── rapport.md # Rapport des modifications + ├── CONDITIONS_GENERALES_DE_VENTE.docx # Copie modifiee + └── ... +``` + +## Utilisation + +### 1. Creer le dossier de la formation + +```bash +mkdir -p formations/excel_dupont_avril2026 +``` + +### 2. Copier et configurer le YAML + +```bash +cp template/formation_template.yaml formations/excel_dupont_avril2026/formation.yaml +``` + +Editer `formation.yaml` avec les infos de la formation : + +```yaml +dossier_source: template + +remplacements: + - ancien: "Word - Initiation bureautique" + nouveau: "Excel Avance - TCD" + + - ancien: "Societe Martin SARL" + nouveau: "Societe Dupont SAS" + + - ancien: "10 et 11 mars 2026" + nouveau: "15 et 16 avril 2026" +``` + +### 3. Lancer le script + +```bash +python3 replace_docx.py formations/excel_dupont_avril2026/formation.yaml +``` + +Le script : +1. Copie les `.docx` du template dans le dossier de la formation +2. Applique tous les remplacements +3. Genere un fichier `rapport.md` dans le dossier de la formation + +### 4. Verifier le rapport + +Ouvrir `formations/excel_dupont_avril2026/rapport.md`. Il contient : +- La date d'execution +- Un tableau par fichier avec les remplacements effectues et le nombre d'occurrences +- La liste des remplacements non trouves (s'il y en a) + +## Options + +### Cibler un fichier specifique + +Par defaut, chaque remplacement s'applique a tous les `.docx`. Pour cibler un fichier : + +```yaml +remplacements: + - ancien: "Texte specifique" + nouveau: "Nouveau texte" + fichiers: ["convention.docx"] +``` + +### Chemins + +Tous les chemins dans le YAML sont relatifs a la racine du projet (dossier contenant `replace_docx.py`). + +## Ce qui est pris en charge + +| Zone du document | Supporte | +|---|---| +| Paragraphes du corps | Oui | +| Cellules de tableaux | Oui | +| Tableaux imbriques | Oui | +| En-tetes de page | Oui | +| Pieds de page | Oui | +| Formatage (police, taille, couleur, gras) | Preserve | +| Texte decoupe sur plusieurs runs | Gere automatiquement | + +## Utilisation avec Claude + +Le skill `/formation-docx-replacer` permet de preparer un dossier automatiquement : + +``` +/formation-docx-replacer ma_nouvelle_formation +``` + +Claude cree le dossier dans `formations/`, copie le template YAML et indique la marche a suivre. diff --git a/replace_docx.py b/replace_docx.py new file mode 100644 index 0000000..5e965df --- /dev/null +++ b/replace_docx.py @@ -0,0 +1,272 @@ +#!/usr/bin/env python3 +""" +Script de remplacement de texte dans des fichiers .docx +Lit un fichier YAML de configuration et applique les remplacements +dans tous les .docx d'un dossier en préservant le formatage. +""" + +import sys +import os +import shutil +import yaml +from docx import Document +from copy import deepcopy + + +def get_full_text_from_runs(runs): + """Reconstitue le texte complet à partir des runs.""" + return "".join(r.text for r in runs) + + +def replace_in_runs(runs, ancien, nouveau): + """ + Remplace du texte dans une liste de runs en préservant le formatage. + Gère le cas où le texte est découpé sur plusieurs runs. + Retourne le nombre de remplacements effectués. + """ + count = 0 + full_text = get_full_text_from_runs(runs) + + if ancien not in full_text: + return 0 + + while ancien in full_text: + count += 1 + start_idx = full_text.index(ancien) + end_idx = start_idx + len(ancien) + + # Trouver quels runs sont concernés + char_pos = 0 + start_run = None + end_run = None + + for i, run in enumerate(runs): + run_start = char_pos + run_end = char_pos + len(run.text) + + if start_run is None and run_end > start_idx: + start_run = i + start_offset = start_idx - run_start + + if run_end >= end_idx: + end_run = i + end_offset = end_idx - run_start + break + + char_pos = run_end + + if start_run is None or end_run is None: + break + + # Cas simple : tout dans un seul run + if start_run == end_run: + run = runs[start_run] + run.text = run.text[:start_offset] + nouveau + run.text[end_offset:] + else: + # Multi-run : mettre le nouveau texte dans le premier run, + # vider les runs intermédiaires, ajuster le dernier + runs[start_run].text = runs[start_run].text[:start_offset] + nouveau + + for i in range(start_run + 1, end_run): + runs[i].text = "" + + runs[end_run].text = runs[end_run].text[end_offset:] + + full_text = get_full_text_from_runs(runs) + + return count + + +def replace_in_paragraph(paragraph, ancien, nouveau): + """Remplace du texte dans un paragraphe.""" + if not paragraph.runs: + return 0 + return replace_in_runs(paragraph.runs, ancien, nouveau) + + +def replace_in_table(table, ancien, nouveau): + """Remplace du texte dans toutes les cellules d'un tableau.""" + count = 0 + for row in table.rows: + for cell in row.cells: + for paragraph in cell.paragraphs: + count += replace_in_paragraph(paragraph, ancien, nouveau) + # Tables imbriquées + for nested_table in cell.tables: + count += replace_in_table(nested_table, ancien, nouveau) + return count + + +def replace_in_headers_footers(doc, ancien, nouveau): + """Remplace du texte dans les en-têtes et pieds de page.""" + count = 0 + for section in doc.sections: + for header in [section.header, section.first_page_header, section.even_page_header]: + if header and header.is_linked_to_previous is False: + for paragraph in header.paragraphs: + count += replace_in_paragraph(paragraph, ancien, nouveau) + for table in header.tables: + count += replace_in_table(table, ancien, nouveau) + + for footer in [section.footer, section.first_page_footer, section.even_page_footer]: + if footer and footer.is_linked_to_previous is False: + for paragraph in footer.paragraphs: + count += replace_in_paragraph(paragraph, ancien, nouveau) + for table in footer.tables: + count += replace_in_table(table, ancien, nouveau) + return count + + +def process_docx(filepath, remplacements): + """Applique tous les remplacements sur un fichier .docx.""" + doc = Document(filepath) + rapport = {} + + for remp in remplacements: + ancien = remp["ancien"] + nouveau = remp["nouveau"] + fichiers_cibles = remp.get("fichiers", None) + + # Si des fichiers cibles sont spécifiés, vérifier que ce fichier en fait partie + if fichiers_cibles: + basename = os.path.basename(filepath) + if basename not in fichiers_cibles: + continue + + count = 0 + + # Paragraphes du corps + for paragraph in doc.paragraphs: + count += replace_in_paragraph(paragraph, ancien, nouveau) + + # Tableaux + for table in doc.tables: + count += replace_in_table(table, ancien, nouveau) + + # En-têtes et pieds de page + count += replace_in_headers_footers(doc, ancien, nouveau) + + if count > 0: + rapport[f"'{ancien}' -> '{nouveau}'"] = count + + doc.save(filepath) + return rapport + + +def main(): + if len(sys.argv) < 2: + print("Usage: python3 replace_docx.py ") + sys.exit(1) + + config_path = os.path.abspath(sys.argv[1]) + config_dir = os.path.dirname(config_path) + + with open(config_path, "r", encoding="utf-8") as f: + config = yaml.safe_load(f) + + # Les chemins sont relatifs à la racine du projet (dossier contenant replace_docx.py) + project_dir = os.path.dirname(os.path.abspath(__file__)) + + dossier_source = config["dossier_source"] + if not os.path.isabs(dossier_source): + dossier_source = os.path.join(project_dir, dossier_source) + + remplacements = config["remplacements"] + + # Le dossier de travail est celui qui contient le fichier YAML + dossier_travail = config_dir + + # Copier les .docx du template dans le dossier de travail + docx_copies = 0 + for f in os.listdir(dossier_source): + if f.endswith(".docx") and not f.startswith("~$"): + src = os.path.join(dossier_source, f) + dst = os.path.join(dossier_travail, f) + if not os.path.exists(dst): + shutil.copy2(src, dst) + docx_copies += 1 + + if docx_copies > 0: + print(f"{docx_copies} fichier(s) .docx copie(s) depuis {dossier_source}") + else: + print(f"Fichiers .docx deja presents dans {dossier_travail}") + + # Trouver tous les .docx du dossier de travail + docx_files = [] + for f in os.listdir(dossier_travail): + if f.endswith(".docx") and not f.startswith("~$"): + docx_files.append(os.path.join(dossier_travail, f)) + + if not docx_files: + print("Aucun fichier .docx trouve.") + sys.exit(1) + + print(f"\n{len(docx_files)} fichier(s) .docx trouve(s)\n") + + # Appliquer les remplacements + rapport_global = {} + for filepath in sorted(docx_files): + rapport = process_docx(filepath, remplacements) + rel_path = os.path.relpath(filepath, dossier_travail) + rapport_global[rel_path] = rapport + + # Identifier les remplacements non trouves + remplacements_non_trouves = set() + for remp in remplacements: + cle = f"'{remp['ancien']}' -> '{remp['nouveau']}'" + trouve = False + for fichier, rapport in rapport_global.items(): + if cle in rapport: + trouve = True + break + if not trouve: + remplacements_non_trouves.add(cle) + + # Generer le rapport markdown + from datetime import datetime + lignes = [] + lignes.append(f"# Rapport de modifications") + lignes.append(f"") + lignes.append(f"**Date** : {datetime.now().strftime('%d/%m/%Y %H:%M')}") + lignes.append(f"**Template** : `{os.path.relpath(dossier_source, project_dir)}`") + lignes.append(f"**Fichiers traites** : {len(docx_files)}") + lignes.append(f"") + + lignes.append(f"## Modifications par fichier") + lignes.append(f"") + for fichier, rapport in sorted(rapport_global.items()): + lignes.append(f"### {fichier}") + lignes.append(f"") + if rapport: + lignes.append(f"| Ancien | Nouveau | Occurrences |") + lignes.append(f"|---|---|---|") + for remp, count in rapport.items(): + # remp est au format "'ancien' -> 'nouveau'" + parties = remp.split("' -> '") + ancien_txt = parties[0][1:] # enlever le ' du debut + nouveau_txt = parties[1][:-1] # enlever le ' de la fin + lignes.append(f"| {ancien_txt} | {nouveau_txt} | {count} |") + else: + lignes.append(f"Aucun remplacement effectue.") + lignes.append(f"") + + if remplacements_non_trouves: + lignes.append(f"## Remplacements non trouves") + lignes.append(f"") + lignes.append(f"Les remplacements suivants n'ont ete trouves dans aucun fichier :") + lignes.append(f"") + for r in remplacements_non_trouves: + lignes.append(f"- {r}") + lignes.append(f"") + + # Ecrire le rapport + rapport_path = os.path.join(dossier_travail, "rapport.md") + with open(rapport_path, "w", encoding="utf-8") as f: + f.write("\n".join(lignes)) + + print(f"Rapport genere : {rapport_path}") + print("Termine!") + + +if __name__ == "__main__": + main() diff --git a/template/CONDITIONS_GENERALES_DE_VENTE.docx b/template/CONDITIONS_GENERALES_DE_VENTE.docx new file mode 100644 index 0000000..7646c93 Binary files /dev/null and b/template/CONDITIONS_GENERALES_DE_VENTE.docx differ diff --git a/template/formation_template.yaml b/template/formation_template.yaml new file mode 100644 index 0000000..bb76fe7 --- /dev/null +++ b/template/formation_template.yaml @@ -0,0 +1,43 @@ +# Configuration de remplacement pour dossier de formation +# Usage: +# 1. Creer un dossier pour la formation : mkdir formations/ma_formation +# 2. Copier ce fichier dedans : cp template/formation_template.yaml formations/ma_formation/formation.yaml +# 3. Editer le fichier copie avec les bonnes infos +# 4. Lancer : python3 replace_docx.py formations/ma_formation/formation.yaml +# +# Le script copie automatiquement les .docx du template dans le dossier de la formation. +# Les chemins sont relatifs a la racine du projet. + +# Dossier contenant les .docx template +dossier_source: template + +# Liste des remplacements (appliques a TOUS les .docx copies) +remplacements: + # Infos formation + - ancien: "Word - Initiation bureautique" + nouveau: "Excel Avance - Tableaux croises dynamiques" + + # Infos client + - ancien: "Societe Martin SARL" + nouveau: "Societe Dupont SAS" + + # Dates + - ancien: "10 et 11 mars 2026" + nouveau: "15 et 16 avril 2026" + + # Formateur + - ancien: "Marie Martin" + nouveau: "Jean Dupont" + + # Lieu + - ancien: "14 allee d'Argenson, Chatellerault" + nouveau: "12 rue des Lilas, Poitiers" + + # Duree + - ancien: "7 heures (1 jour)" + nouveau: "14 heures (2 jours)" + + # Remplacement cible sur un seul fichier (optionnel) + # - ancien: "Texte specifique a la convention" + # nouveau: "Nouveau texte convention" + # fichiers: ["convention.docx"]