Files
qualiopi_helper/replace_docx.py

278 lines
9.3 KiB
Python

#!/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 <fichier_config.yaml>")
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()