remporte 0.4.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. remporte/__init__.py +8 -0
  2. remporte/__main__.py +10 -0
  3. remporte/cadre.py +238 -0
  4. remporte/candidature.py +302 -0
  5. remporte/cerfa/DC1-2019.docx +0 -0
  6. remporte/cerfa/DC2-2023.docx +0 -0
  7. remporte/cerfa/DC4-2023.docx +0 -0
  8. remporte/cerfa/cartes.json +99 -0
  9. remporte/charte.py +79 -0
  10. remporte/cli.py +807 -0
  11. remporte/espace.py +281 -0
  12. remporte/export.py +631 -0
  13. remporte/formats.py +154 -0
  14. remporte/gabarits/02-analyse.md +49 -0
  15. remporte/gabarits/03-go-no-go.md +20 -0
  16. remporte/gabarits/04-plan.md +21 -0
  17. remporte/gabarits/05-relecture.md +25 -0
  18. remporte/gabarits/AGENTS-espace.md +18 -0
  19. remporte/gabarits/AGENTS.md +27 -0
  20. remporte/gabarits/CLAUDE.md +1 -0
  21. remporte/gabarits/fiche-entreprise.md +51 -0
  22. remporte/guides/.gitkeep +0 -0
  23. remporte/guides/analyse.md +53 -0
  24. remporte/guides/candidature.md +20 -0
  25. remporte/guides/excel.md +29 -0
  26. remporte/guides/export.md +20 -0
  27. remporte/guides/formats.md +18 -0
  28. remporte/guides/go-no-go.md +22 -0
  29. remporte/guides/modeles.md +38 -0
  30. remporte/guides/offre.md +19 -0
  31. remporte/guides/pieces.md +14 -0
  32. remporte/guides/plan.md +42 -0
  33. remporte/guides/redaction.md +46 -0
  34. remporte/guides/relecture.md +28 -0
  35. remporte/guides/soutenance.md +25 -0
  36. remporte/inventaire.py +153 -0
  37. remporte/lecture.py +558 -0
  38. remporte/lien.py +15 -0
  39. remporte/recherche.py +190 -0
  40. remporte/travail.py +57 -0
  41. remporte-0.4.2.dist-info/METADATA +242 -0
  42. remporte-0.4.2.dist-info/RECORD +45 -0
  43. remporte-0.4.2.dist-info/WHEEL +4 -0
  44. remporte-0.4.2.dist-info/entry_points.txt +2 -0
  45. remporte-0.4.2.dist-info/licenses/LICENSE +21 -0
remporte/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """CLI remporte — répondre à un appel d'offres publics avec votre agent IA."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("remporte")
7
+ except PackageNotFoundError: # exécuté depuis les sources sans installation
8
+ __version__ = "0.0.0"
remporte/__main__.py ADDED
@@ -0,0 +1,10 @@
1
+ """Point d'entrée `python -m remporte`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ from remporte.cli import main
8
+
9
+ if __name__ == "__main__":
10
+ sys.exit(main())
remporte/cadre.py ADDED
@@ -0,0 +1,238 @@
1
+ """Extraction déterministe d'un cadre de réponse depuis la pièce de l'acheteur.
2
+
3
+ Port de `remporte-pi/apps/pipe/src/remporte_ai/ingestion/response_frame.py`
4
+ (lu sur origin/main). Deux voies : les paragraphes d'un style dédié du DOCX
5
+ (trame bien faite), puis les phrases « le candidat … » dans le texte converti.
6
+ En dessous de trois points, le cadre est jugé inexploitable : origine « aucune ».
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from collections import Counter
13
+ import unicodedata
14
+ from pathlib import Path
15
+
16
+ from remporte.lecture import convertir
17
+
18
+ _DEMANDE_CANDIDAT = re.compile(
19
+ r"\b(?:le candidat|le soumissionnaire|l[' ]entreprise)\s+"
20
+ r"(?:precise|decrit|detaille|fournit|indique|justifie"
21
+ r"|precisera|decrira|detaillera|fournira|indiquera|justifiera)\b"
22
+ )
23
+ _STYLE_TITRE = re.compile(r"^(?:heading|titre|title)(?:\s+\d+)?(?:\s|$)")
24
+
25
+ _SEUIL_POINTS = 3
26
+ _LIMITE_TITRE = 140
27
+
28
+
29
+ def _normaliser(texte: str) -> str:
30
+ """Normalise un texte pour les comparaisons et empreintes stables."""
31
+ sans_accents = "".join(
32
+ caractere
33
+ for caractere in unicodedata.normalize("NFKD", texte)
34
+ if not unicodedata.combining(caractere)
35
+ )
36
+ return " ".join(sans_accents.lower().replace("’", "'").split())
37
+
38
+
39
+ def _est_demande_candidat(texte: str) -> bool:
40
+ return bool(_DEMANDE_CANDIDAT.search(_normaliser(texte)))
41
+
42
+
43
+ def _est_titre(nom_style: str) -> bool:
44
+ return bool(_STYLE_TITRE.match(_normaliser(nom_style)))
45
+
46
+
47
+ def _est_style_dedie(style) -> bool:
48
+ """Écarte les styles Word intégrés, notamment le style Normal."""
49
+ return not style.builtin
50
+
51
+
52
+ def _finalise(origine: str, points_bruts: list[tuple[str, str]]) -> dict:
53
+ """Applique le seuil minimal d'un cadre exploitable."""
54
+ if len(points_bruts) < _SEUIL_POINTS:
55
+ return {"origine": "aucune", "points": []}
56
+ points = [
57
+ {
58
+ "numero": index,
59
+ "titre": _tronquer(texte),
60
+ "demande": texte,
61
+ "niveau": chapitre,
62
+ }
63
+ for index, (texte, chapitre) in enumerate(points_bruts, 1)
64
+ ]
65
+ return {"origine": origine, "points": points}
66
+
67
+
68
+ def _tronquer(texte: str, limite: int = _LIMITE_TITRE) -> str:
69
+ return texte if len(texte) <= limite else texte[: limite - 1].rstrip() + "…"
70
+
71
+
72
+ def _points_par_style(chemin: Path) -> list[tuple[str, str]]:
73
+ """Extrait les paragraphes du style dédié le plus représentatif."""
74
+ from docx import Document
75
+
76
+ document = Document(str(chemin))
77
+ paragraphes = list(document.paragraphs)
78
+ candidats: list[tuple[int, int, str]] = []
79
+
80
+ for style in document.styles:
81
+ try:
82
+ if not _est_style_dedie(style):
83
+ continue
84
+ except Exception: # noqa: BLE001 — style exotique : ignoré
85
+ continue
86
+ indices = [
87
+ index
88
+ for index, paragraphe in enumerate(paragraphes)
89
+ if (
90
+ paragraphe.text.strip()
91
+ and paragraphe.style
92
+ and paragraphe.style.name == style.name
93
+ and _est_demande_candidat(paragraphe.text)
94
+ )
95
+ ]
96
+ if indices:
97
+ candidats.append((len(indices), indices[0], style.name))
98
+
99
+ if not candidats:
100
+ return []
101
+
102
+ _, _, nom_style = min(
103
+ candidats,
104
+ key=lambda candidat: (-candidat[0], candidat[1], candidat[2]),
105
+ )
106
+
107
+ chapitre = ""
108
+ points: list[tuple[str, str]] = []
109
+ for paragraphe in paragraphes:
110
+ texte = " ".join(paragraphe.text.split())
111
+ if not texte:
112
+ continue
113
+ nom = paragraphe.style.name if paragraphe.style else ""
114
+ if _est_titre(nom):
115
+ chapitre = texte
116
+ continue
117
+ if nom == nom_style:
118
+ points.append((texte, chapitre))
119
+ return points
120
+
121
+
122
+ def _points_par_motif(texte: str) -> list[tuple[str, str]]:
123
+ """Extrait les demandes explicites depuis un texte déjà converti."""
124
+ chapitre = ""
125
+ points: list[tuple[str, str]] = []
126
+ for ligne in texte.splitlines():
127
+ contenu = " ".join(ligne.split())
128
+ titre = re.match(r"^#{1,6}\s+(.+?)\s*$", contenu)
129
+ if titre:
130
+ chapitre = titre.group(1)
131
+ continue
132
+ if contenu and _est_demande_candidat(contenu):
133
+ points.append((contenu, chapitre))
134
+ return points
135
+
136
+
137
+ # « {PP 001.}\t2.3.1 : Qualité des équipes : Le candidat précise …\t8 » :
138
+ # liste d'exigences numérotées, fréquente en fin de CRT (la dernière colonne
139
+ # est la page). Les styles n'en voient qu'une partie ; quand elle existe,
140
+ # c'est elle qui fait foi, car l'acheteur note point par point.
141
+ _LIGNE_EXIGENCE = re.compile(
142
+ r"^\{?\s*([A-Z]{1,6}[ _-]?\d{1,4})\s*\.?\s*\}?\s+(.+?)(?:\s+\d{1,4})?\s*$",
143
+ re.MULTILINE,
144
+ )
145
+ _SEUIL_LISTE = 5
146
+
147
+
148
+ def _points_par_liste(texte: str) -> list[tuple[str, str]]:
149
+ """Exigences numérotées (identifiant + chapitre + demande), sans doublon.
150
+
151
+ On garde le préfixe d'identifiant le plus fréquent (« PP ») : c'est lui qui
152
+ signe la liste de l'acheteur, quelle que soit la tournure de la demande.
153
+ """
154
+ lignes: list[tuple[str, str, str]] = []
155
+ for trouve in _LIGNE_EXIGENCE.finditer(texte):
156
+ identifiant = " ".join(trouve.group(1).replace("_", " ").replace("-", " ").split())
157
+ prefixe = re.match(r"[A-Z]+", identifiant).group(0)
158
+ lignes.append((prefixe, identifiant, trouve.group(2).replace("\xa0", " ").strip()))
159
+ if not lignes:
160
+ return []
161
+ prefixes = Counter(prefixe for prefixe, _, _ in lignes)
162
+ dominant, nombre = prefixes.most_common(1)[0]
163
+ if nombre < _SEUIL_LISTE:
164
+ return []
165
+ points: list[tuple[str, str]] = []
166
+ vus: set[str] = set()
167
+ for prefixe, identifiant, corps in lignes:
168
+ if prefixe != dominant or identifiant in vus:
169
+ continue
170
+ vus.add(identifiant)
171
+ chapitre, _, demande = corps.rpartition(" : ")
172
+ if not chapitre:
173
+ demande = corps
174
+ points.append((f"{identifiant} — {' '.join(demande.split())}", " ".join(chapitre.split())))
175
+ return points
176
+
177
+
178
+ def identifiant_du_point(point: dict) -> str | None:
179
+ """« PP 009 » d'un point issu d'une liste numérotée, sinon None."""
180
+ trouve = re.match(r"([A-Z]+) ?(\d+) — ", point["titre"])
181
+ return f"{trouve.group(1)} {trouve.group(2)}" if trouve else None
182
+
183
+
184
+ def couverture(dossier: Path, resultat: dict) -> list[dict] | None:
185
+ """Pour chaque exigence numérotée du CRT : les sections qui la citent.
186
+
187
+ Une section « cite » l'exigence quand son texte contient l'identifiant
188
+ (« PP 009 », tolérant sur séparateurs et zéros de tête). Rend une entrée
189
+ {point, identifiant, sections (noms de fichiers), couverte} par point, ou
190
+ None si le cadre n'est pas une liste numérotée (origine != « liste ») : le
191
+ contrôle point par point n'a alors pas de sens.
192
+ """
193
+ if resultat.get("origine") != "liste":
194
+ return None
195
+ sections = {
196
+ fichier.name: fichier.read_text(encoding="utf-8", errors="replace")
197
+ for fichier in sorted((Path(dossier) / "sections").glob("*.md"), key=lambda p: p.as_posix())
198
+ }
199
+ entrees: list[dict] = []
200
+ for point in resultat["points"]:
201
+ identifiant = identifiant_du_point(point)
202
+ citantes: list[str] = []
203
+ if identifiant:
204
+ prefixe, numero = identifiant.split()
205
+ motif = rf"(?<![A-Za-z]){prefixe}[ _-]?0*{int(numero)}(?!\d)"
206
+ citantes = [
207
+ nom for nom, texte in sections.items() if re.search(motif, texte)
208
+ ]
209
+ entrees.append({
210
+ "point": point,
211
+ "identifiant": identifiant,
212
+ "sections": citantes,
213
+ "couverte": bool(citantes),
214
+ })
215
+ return entrees
216
+
217
+
218
+ def extraire_cadre(piece: Path) -> dict:
219
+ """Trame imposée par l'acheteur (CRT Word).
220
+
221
+ Rend {"origine": "liste"|"styles"|"motifs"|"aucune", "points": [{"numero", "titre",
222
+ "demande", "niveau"}]} où `titre` est la demande (tronquée pour l'affichage),
223
+ `demande` le texte intégral du paragraphe et `niveau` le chapitre (titre de
224
+ section) sous lequel la demande figure.
225
+ """
226
+ piece = Path(piece)
227
+ conversion = convertir(piece)
228
+ liste = _points_par_liste(conversion.texte) if conversion.statut == "ok" else []
229
+ if liste:
230
+ return _finalise("liste", liste)
231
+ if piece.suffix.lower() == ".docx":
232
+ points = _points_par_style(piece)
233
+ if points:
234
+ return _finalise("styles", points)
235
+ return _finalise("motifs", _points_par_motif(conversion.texte))
236
+ if conversion.statut != "ok":
237
+ return {"origine": "aucune", "points": []}
238
+ return _finalise("motifs", _points_par_motif(conversion.texte))
@@ -0,0 +1,302 @@
1
+ """Formulaires de candidature DC1/DC2/DC4 : préparation des valeurs et remplissage.
2
+
3
+ `preparer` crée `candidature/valeurs.json` (une entrée par formulaire, chaque
4
+ champ à null) et ne remplace jamais un fichier existant. `remplir` lit ces
5
+ valeurs et écrit `candidature/DC1.docx`, `DC2.docx` et `DC4.docx` (ce dernier
6
+ seulement si une sous-traitance est déclarée), en écrivant dans le XML des
7
+ formulaires officiels vierges de `cerfa/` :
8
+
9
+ - texte : la valeur est ajoutée à la fin du paragraphe repéré par l'ancre de
10
+ la carte (les libellés Cerfa sont coupés entre plusieurs runs : on ne
11
+ remplace jamais dans un run, on concatène le paragraphe) ;
12
+ - case à cocher : cochée par index 1-based dans l'ordre du document. DC1 et
13
+ DC2 portent des champs de formulaire `<w:checkBox>` ; les cases du DC4 sont
14
+ des formes dessinées, cochées en les noircissant (choix DrawingML et repli
15
+ VML modifiés ensemble).
16
+
17
+ Un champ sans valeur reste vide et est listé ; le fichier produit est un zip
18
+ reconstruit à l'identique, contrôlé comme XML bien formé.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import importlib.resources
24
+ import io
25
+ import json
26
+ import re
27
+ import zipfile
28
+ from pathlib import Path
29
+ from xml.etree import ElementTree
30
+ from xml.sax.saxutils import escape
31
+
32
+ from remporte import espace
33
+
34
+ _RE_PARAGRAPHE = re.compile(r"<w:p[ >].*?</w:p>", re.S)
35
+ _RE_TEXTE = re.compile(r"(?P<ouvrant><w:t[^>]*>)(?P<texte>[^<]*)(?P<fermant></w:t>)")
36
+ _RE_TEXTE_PLAIN = re.compile(r"<w:t[^>]*>([^<]*)</w:t>")
37
+ _RE_CASE = re.compile(r"<w:checkBox>.*?</w:checkBox>", re.S)
38
+ _RE_BLOC_DESSINE = re.compile(r"<mc:AlternateContent>.*?</mc:AlternateContent>", re.S)
39
+
40
+ # Les cases du DC4 sont les seules formes dessinées de cette géométrie
41
+ # (carré de 11,7 pt, `filled="f"`) ; les autres blocs dessinés (traits,
42
+ # encadrés) en diffèrent.
43
+ _GEOMETRIE_CASE_DC4 = 'coordsize="147955,147955"'
44
+ _SEPARATEUR = " " # double espace entre le libellé et la valeur ajoutée
45
+
46
+
47
+ def cartes() -> dict:
48
+ """Contenu de `cerfa/cartes.json` : formulaires, champs, ancres et cases."""
49
+ ressource = importlib.resources.files("remporte") / "cerfa" / "cartes.json"
50
+ return json.loads(ressource.read_text(encoding="utf-8"))
51
+
52
+
53
+ def preparer(dossier: Path) -> Path:
54
+ """Crée `candidature/valeurs.json` avec chaque champ à null. Jamais de
55
+ remplacement d'un fichier existant."""
56
+ dossier = Path(dossier)
57
+ if not (dossier / ".remporte").is_dir():
58
+ raise espace.Erreur(
59
+ f"{dossier} n'est pas un dossier de réponse (pas de .remporte/). "
60
+ "Lancez `remporte init <DCE>` d'abord."
61
+ )
62
+ cible = dossier / "candidature" / "valeurs.json"
63
+ if cible.exists():
64
+ raise espace.Erreur(
65
+ f"{cible} existe déjà. Complétez-le tel quel ; il n'est jamais "
66
+ "remplacé par l'outil."
67
+ )
68
+ valeurs = {
69
+ formulaire: {
70
+ "fichier": info["fichier"],
71
+ "label": info["label"],
72
+ "champs": {
73
+ champ["var"]: {
74
+ "rubrique": champ["rubrique"],
75
+ "label": champ["label"],
76
+ "type": champ["type"],
77
+ "options": champ.get("options"),
78
+ "valeur": None,
79
+ }
80
+ for champ in info["champs"]
81
+ },
82
+ }
83
+ for formulaire, info in cartes()["formulaires"].items()
84
+ }
85
+ cible.parent.mkdir(parents=True, exist_ok=True)
86
+ cible.write_text(
87
+ json.dumps(valeurs, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
88
+ )
89
+ return cible
90
+
91
+
92
+ def remplir(dossier: Path) -> dict:
93
+ """Écrit les formulaires renseignés dans `candidature/`. Rend les chemins
94
+ produits, les champs non remplis, les optionnels vides, les champs à porter
95
+ à la main et les anomalies (ancre ou case introuvable)."""
96
+ dossier = Path(dossier)
97
+ chemin_valeurs = dossier / "candidature" / "valeurs.json"
98
+ if not chemin_valeurs.exists():
99
+ raise espace.Erreur(
100
+ f"{chemin_valeurs} est absent : lancez `remporte candidature "
101
+ "preparer` d'abord, complétez valeurs.json, puis relancez."
102
+ )
103
+ valeurs = json.loads(chemin_valeurs.read_text(encoding="utf-8"))
104
+ resultat: dict = {
105
+ "produits": [],
106
+ "non_produits": [],
107
+ "non_remplis": {},
108
+ "optionnels_vides": {},
109
+ "manuels": {},
110
+ "anomalies": {},
111
+ }
112
+ for formulaire, info in cartes()["formulaires"].items():
113
+ renseigne = valeurs.get(formulaire, {}).get("champs", {})
114
+ if formulaire == "DC4" and renseigne.get("sous_traitance", {}).get(
115
+ "valeur"
116
+ ) is not True:
117
+ resultat["non_produits"].append({
118
+ "formulaire": formulaire,
119
+ "motif": "pas de sous-traitance déclarée "
120
+ "(champ sous_traitance de valeurs.json)",
121
+ })
122
+ continue
123
+ _remplir_formulaire(dossier, formulaire, info, renseigne, resultat)
124
+ return resultat
125
+
126
+
127
+ # ---------------------------------------------------------------------------
128
+ # Remplissage d'un formulaire
129
+ # ---------------------------------------------------------------------------
130
+
131
+ def _remplir_formulaire(dossier: Path, formulaire: str, info: dict,
132
+ renseigne: dict, resultat: dict) -> None:
133
+ non_remplis: list[str] = []
134
+ optionnels_vides: list[str] = []
135
+ manuels: list[str] = []
136
+ anomalies: dict[str, str] = {}
137
+ xml = _xml_vierge(info["fichier"])
138
+ for champ in info["champs"]:
139
+ var = champ["var"]
140
+ valeur = renseigne.get(var, {}).get("valeur")
141
+ if champ["type"] == "manuelle":
142
+ manuels.append(var)
143
+ continue
144
+ if valeur is None or valeur == "":
145
+ (optionnels_vides if champ.get("optionnel") else non_remplis).append(var)
146
+ continue
147
+ erreur = None
148
+ if champ["type"] == "texte":
149
+ xml, erreur = _ecrire_texte(xml, champ, str(valeur))
150
+ elif champ["type"] == "pointilles":
151
+ xml, erreur = _ecrire_pointilles(xml, champ, str(valeur))
152
+ elif champ["type"] == "choix":
153
+ options = champ.get("options") or {}
154
+ if valeur not in options:
155
+ erreur = f"valeur « {valeur} » hors choix : {', '.join(options)}"
156
+ else:
157
+ xml, erreur = (_noircir_case_dessinee(xml, options[valeur])
158
+ if formulaire == "DC4"
159
+ else _cocher_case(xml, options[valeur]))
160
+ elif champ["type"] == "drapeau":
161
+ continue # déjà traité (déclencheur de production du DC4)
162
+ if erreur:
163
+ anomalies[var] = erreur
164
+ if anomalies:
165
+ resultat["anomalies"][formulaire] = anomalies
166
+ if non_remplis:
167
+ resultat["non_remplis"][formulaire] = non_remplis
168
+ if optionnels_vides:
169
+ resultat["optionnels_vides"][formulaire] = optionnels_vides
170
+ if manuels:
171
+ resultat["manuels"][formulaire] = manuels
172
+
173
+ _controler_xml(xml, formulaire, info["fichier"])
174
+ cible = dossier / "candidature" / f"{formulaire}.docx"
175
+ _ecrire_docx(info["fichier"], xml, cible)
176
+ resultat["produits"].append({
177
+ "formulaire": formulaire,
178
+ "chemin": cible.relative_to(dossier).as_posix(),
179
+ })
180
+
181
+
182
+ def _xml_vierge(nom_fichier: str) -> str:
183
+ ressource = importlib.resources.files("remporte") / "cerfa" / nom_fichier
184
+ with zipfile.ZipFile(io.BytesIO(ressource.read_bytes())) as archive:
185
+ return archive.read("word/document.xml").decode("utf-8")
186
+
187
+
188
+ def _controler_xml(xml: str, formulaire: str, nom_fichier: str) -> None:
189
+ try:
190
+ ElementTree.fromstring(xml)
191
+ except ElementTree.ParseError as erreur:
192
+ raise espace.Erreur(
193
+ f"{formulaire} ({nom_fichier}) : XML invalide après remplissage "
194
+ f"({erreur}). Aucun fichier n'a été écrit."
195
+ ) from erreur
196
+
197
+
198
+ def _ecrire_docx(nom_fichier: str, xml: str, cible: Path) -> None:
199
+ """Reconstruit le docx du formulaire avec le document.xml modifié."""
200
+ ressource = importlib.resources.files("remporte") / "cerfa" / nom_fichier
201
+ with zipfile.ZipFile(io.BytesIO(ressource.read_bytes())) as archive:
202
+ contenu = {nom: archive.read(nom) for nom in archive.namelist()}
203
+ contenu["word/document.xml"] = xml.encode("utf-8")
204
+ cible.parent.mkdir(parents=True, exist_ok=True)
205
+ with zipfile.ZipFile(cible, "w", zipfile.ZIP_DEFLATED) as sortie:
206
+ for nom, donnees in contenu.items():
207
+ sortie.writestr(nom, donnees)
208
+
209
+
210
+ # ---------------------------------------------------------------------------
211
+ # Écritures dans le XML
212
+ # ---------------------------------------------------------------------------
213
+
214
+ def _paragraphe_cible(xml: str, ancre: str, occurrence: int
215
+ ) -> re.Match | None:
216
+ """N-ième paragraphe dont le texte concaténé contient l'ancre."""
217
+ vues = 0
218
+ for trouve in _RE_PARAGRAPHE.finditer(xml):
219
+ texte = "".join(_RE_TEXTE_PLAIN.findall(trouve.group(0)))
220
+ if ancre in texte:
221
+ vues += 1
222
+ if vues == occurrence:
223
+ return trouve
224
+ return None
225
+
226
+
227
+ def _ecrire_texte(xml: str, champ: dict, valeur: str) -> tuple[str, str | None]:
228
+ """Ajoute la valeur à la fin du paragraphe repéré par l'ancre (dans le
229
+ dernier run texte, jamais au milieu d'un libellé coupé entre runs)."""
230
+ ancre = champ["ancre"]
231
+ occurrence = champ.get("occurrence", 1)
232
+ trouve = _paragraphe_cible(xml, ancre, occurrence)
233
+ if trouve is None:
234
+ return xml, f"ancre introuvable : « {ancre} » (occurrence {occurrence})"
235
+ paragraphe = trouve.group(0)
236
+ runs = list(_RE_TEXTE.finditer(paragraphe))
237
+ if not runs:
238
+ return xml, f"ancre « {ancre} » : paragraphe sans texte modifiable"
239
+ dernier = runs[-1]
240
+ ouvrant = dernier.group("ouvrant")
241
+ if "xml:space" not in ouvrant:
242
+ ouvrant = ouvrant[:-1] + ' xml:space="preserve">'
243
+ remplace = (ouvrant + dernier.group("texte")
244
+ + escape(_SEPARATEUR + valeur) + dernier.group("fermant"))
245
+ nouveau = paragraphe[:dernier.start()] + remplace + paragraphe[dernier.end():]
246
+ return xml[:trouve.start()] + nouveau + xml[trouve.end():], None
247
+
248
+
249
+ def _ecrire_pointilles(xml: str, champ: dict, valeur: str) -> tuple[str, str | None]:
250
+ """Écrit la valeur dans les pointillés qui suivent l'ancre (DC1 : lot n°)."""
251
+ ancre = champ["ancre"]
252
+ occurrence = champ.get("occurrence", 1)
253
+ trouve = _paragraphe_cible(xml, ancre, occurrence)
254
+ if trouve is None:
255
+ return xml, f"ancre introuvable : « {ancre} » (occurrence {occurrence})"
256
+ paragraphe = trouve.group(0)
257
+ motif = re.compile(re.escape(ancre) + r"[…. \s]+")
258
+ nouveau, nombre = motif.subn(
259
+ lambda m: ancre + " " + escape(valeur) + " ", paragraphe, count=1
260
+ )
261
+ if nombre == 0:
262
+ return xml, f"pointillés introuvables après « {ancre} »"
263
+ return xml[:trouve.start()] + nouveau + xml[trouve.end():], None
264
+
265
+
266
+ def _cocher_case(xml: str, index: int) -> tuple[str, str | None]:
267
+ """Coche le n-ième champ `<w:checkBox>` (ordre du document) en insérant
268
+ `<w:checked w:val="1"/>` juste après l'ouverture (ordre du schéma)."""
269
+ cases = list(_RE_CASE.finditer(xml))
270
+ if not 1 <= index <= len(cases):
271
+ return xml, f"case {index} absente (le formulaire en compte {len(cases)})"
272
+ bloc = cases[index - 1]
273
+ contenu = bloc.group(0)
274
+ if "<w:checked" in contenu:
275
+ contenu = re.sub(r"<w:checked[^>]*/>", '<w:checked w:val="1"/>', contenu)
276
+ else:
277
+ contenu = contenu.replace(
278
+ "<w:checkBox>", '<w:checkBox><w:checked w:val="1"/>', 1
279
+ )
280
+ return xml[:bloc.start()] + contenu + xml[bloc.end():], None
281
+
282
+
283
+ def _noircir_case_dessinee(xml: str, index: int) -> tuple[str, str | None]:
284
+ """Noircit la n-ième case dessinée du DC4 (ordre du document) : les cases
285
+ sont des formes présentes en double (choix DrawingML + repli VML), les
286
+ deux représentations sont modifiées pour rester cohérentes. Le filtre ne
287
+ regarde que la géométrie : une case déjà noircie doit rester à son rang,
288
+ sinon les index des cases suivantes décaleraient."""
289
+ cases = [m for m in _RE_BLOC_DESSINE.finditer(xml)
290
+ if _GEOMETRIE_CASE_DC4 in m.group(0)]
291
+ if not 1 <= index <= len(cases):
292
+ return xml, f"case {index} absente (le formulaire en compte {len(cases)})"
293
+ bloc = cases[index - 1]
294
+ contenu = bloc.group(0)
295
+ contenu = contenu.replace(
296
+ 'filled="f"', 'filled="t" fillcolor="#000000"', 1
297
+ )
298
+ contenu = contenu.replace(
299
+ "</a:custGeom>",
300
+ '</a:custGeom><a:solidFill><a:srgbClr val="000000"/></a:solidFill>', 1
301
+ )
302
+ return xml[:bloc.start()] + contenu + xml[bloc.end():], None
Binary file
Binary file
Binary file