#!/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 (y compris sous-dossiers) docx_copies = 0 for root, dirs, files in os.walk(dossier_source): for f in files: if f.endswith(".docx") and not f.startswith("~$"): src = os.path.join(root, f) # Reproduire la structure de sous-dossiers rel = os.path.relpath(src, dossier_source) dst = os.path.join(dossier_travail, rel) os.makedirs(os.path.dirname(dst), exist_ok=True) 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 (y compris sous-dossiers) docx_files = [] for root, dirs, files in os.walk(dossier_travail): for f in files: if f.endswith(".docx") and not f.startswith("~$"): docx_files.append(os.path.join(root, 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()