@tricoteuses/senat 3.1.23 → 3.2.0

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 (41) hide show
  1. package/lib/src/index.d.ts +2 -0
  2. package/lib/src/other_types/plf.d.ts +21 -0
  3. package/lib/src/other_types/plf.js +1 -0
  4. package/lib/src/parsers/collaborateurs.d.ts +24 -24
  5. package/lib/src/parsers/collaborateurs.js +76 -76
  6. package/lib/src/parsers/plf/credits.d.ts +2 -0
  7. package/lib/src/parsers/plf/credits.js +171 -0
  8. package/lib/src/parsers/plf/index.d.ts +1 -0
  9. package/lib/src/parsers/plf/index.js +1 -0
  10. package/lib/src/parsers/plf/parser_utils.d.ts +18 -0
  11. package/lib/src/parsers/plf/parser_utils.js +172 -0
  12. package/lib/src/parsers/texte.d.ts +1 -3
  13. package/lib/src/parsers/texte.js +63 -30
  14. package/lib/src/rich_types/sens.d.ts +5 -0
  15. package/lib/src/scripts/data-download.js +1 -0
  16. package/lib/src/scripts/extract_plf_fixtures.d.ts +1 -0
  17. package/lib/src/scripts/extract_plf_fixtures.js +101 -0
  18. package/lib/src/scripts/retrieve_collaborateurs.js +93 -93
  19. package/lib/src/scripts/retrieve_documents.js +7 -46
  20. package/lib/src/server/parse_document.d.ts +5 -0
  21. package/lib/src/server/parse_document.js +47 -0
  22. package/lib/tests/collaborateurs.test.js +52 -52
  23. package/lib/tests/parsers/parseDocument.test.d.ts +1 -0
  24. package/lib/tests/parsers/parseDocument.test.js +73 -0
  25. package/lib/tests/parsers/plf/credits.integration.test.d.ts +1 -0
  26. package/lib/tests/parsers/plf/credits.integration.test.js +118 -0
  27. package/lib/tests/parsers/plf/credits.test.d.ts +1 -0
  28. package/lib/tests/parsers/plf/credits.test.js +467 -0
  29. package/lib/tests/parsers/plf/integration.test_utils.d.ts +8 -0
  30. package/lib/tests/parsers/plf/integration.test_utils.js +73 -0
  31. package/lib/tests/parsers/plf/test_utils.d.ts +20 -0
  32. package/lib/tests/parsers/plf/test_utils.js +85 -0
  33. package/lib/tests/parsers/texte.test.d.ts +1 -0
  34. package/lib/tests/parsers/texte.test.js +71 -0
  35. package/lib/tests/parsers/texte.test_utils.d.ts +15 -0
  36. package/lib/tests/parsers/texte.test_utils.js +73 -0
  37. package/lib/tests/plf/credits.test.d.ts +1 -0
  38. package/lib/tests/plf/credits.test.js +458 -0
  39. package/lib/tests/plf/test_utils.d.ts +20 -0
  40. package/lib/tests/plf/test_utils.js +85 -0
  41. package/package.json +5 -1
@@ -39,3 +39,5 @@ export type { Session, SessionOrAll } from "./other_types/sessions.js";
39
39
  export { UNDEFINED_SESSION, sessionOptions, sessionOptionsOrAll, getSessionsFromStart } from "./other_types/sessions.js";
40
40
  export { DivisionType } from "./other_types/texte.js";
41
41
  export type { DivisionTag, Version, DocumentMetadata, FlatTexte, Step, Division, Article, DivisionContent, Alinea, ExposeDesMotifs, } from "./other_types/texte.js";
42
+ export type { CollaborateurSenat } from "./other_types/collaborateurs.js";
43
+ export type { PlfBudgetType, PlfLigne, PlfProgramme, PlfMission, PlfCreditsSection } from "./other_types/plf.js";
@@ -0,0 +1,21 @@
1
+ export type PlfBudgetType = "budgetGeneral" | "budgetsAnnexes" | "comptesSpeciaux";
2
+ export interface PlfLigne {
3
+ libelle: string;
4
+ autorisationEngagement?: number;
5
+ creditPaiement?: number;
6
+ autorisationEngagementAnnule?: number;
7
+ creditPaiementAnnule?: number;
8
+ }
9
+ export interface PlfProgramme extends PlfLigne {
10
+ lignes?: PlfLigne[];
11
+ }
12
+ export interface PlfMission extends PlfLigne {
13
+ budgetType: PlfBudgetType;
14
+ programmes: PlfProgramme[];
15
+ }
16
+ export interface PlfCreditsSection {
17
+ budgetType: PlfBudgetType;
18
+ libelle: string;
19
+ missions: PlfMission[];
20
+ total?: Omit<PlfLigne, "libelle">;
21
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -1,44 +1,44 @@
1
1
  /**
2
- * Fonctions pures de parsing du PDF DRH des collaborateurs de sénateurs
2
+ * Pure functions for parsing the HR PDF listing senators' collaborators
3
3
  * (https://www.senat.fr/pubagas/liste_senateurs_collaborateurs.pdf).
4
4
  *
5
- * Le PDF est un tableau à deux colonnes par page :
6
- * - "Employeur" (le sénateur) à gauche, ex. "Mme AESCHLIMANN Marie-Do" ;
7
- * - "Nom Collaborateur" à droite, ex. "M. CHAREF Dahmane".
8
- * Une ligne sans employeur prolonge le sénateur précédent.
5
+ * The PDF is a two-column table per page:
6
+ * - "Employeur" (the senator) on the left, e.g. "Mme AESCHLIMANN Marie-Do" ;
7
+ * - "Nom Collaborateur" on the right, e.g. "M. CHAREF Dahmane".
8
+ * A line without an employer extends the previous senator.
9
9
  *
10
- * Pièges traités ici : aucun matricule dans le PDF, prénoms de sénateurs tronqués
11
- * ("Marie-Do"), noms composés ("DI FOLCO", "MAZENOT CHAPPUY"), accents.
10
+ * Edge cases handled here: no matricule in the PDF, truncated senator first names
11
+ * ("Marie-Do"), compound names ("DI FOLCO", "MAZENOT CHAPPUY"), accents.
12
12
  */
13
- /** Élément de texte positionné extrait du PDF (x croissant vers la droite, y croissant vers le haut). */
13
+ /** Positioned text element extracted from the PDF (x goes right, y goes up). */
14
14
  export type PdfTextItem = {
15
15
  str: string;
16
16
  x: number;
17
17
  y: number;
18
18
  page: number;
19
19
  };
20
- export type PersonneNom = {
20
+ export type PersonName = {
21
21
  civilite: string;
22
22
  nom: string;
23
23
  prenom: string;
24
24
  };
25
- export type LigneCollaborateur = {
26
- senateur: PersonneNom;
27
- collaborateur: PersonneNom;
25
+ export type CollaboratorRow = {
26
+ senateur: PersonName;
27
+ collaborateur: PersonName;
28
28
  };
29
- /** Normalise un texte pour la comparaison : majuscules, sans accents, espaces compactés. */
30
- export declare function normaliserPourComparaison(valeur: string): string;
29
+ /** Normalizes a string for comparison: uppercase, no accents, compacted spaces. */
30
+ export declare function normalizeForComparison(value: string): string;
31
31
  /**
32
- * Sépare une cellule "Civilité NOM(S) Prénom" en {civilite, nom, prenom}.
33
- * Les jetons en majuscules de tête forment le nom (gère les noms composés), le reste le prénom.
34
- * Renvoie null si la cellule n'est pas un nom de personne exploitable.
32
+ * Splits a "Civility LAST_NAME(S) FirstName" cell into {civilite, nom, prenom}.
33
+ * Uppercase head tokens form the name (handles compound names), the rest is the first name.
34
+ * Returns null if the cell is not a usable person name.
35
35
  */
36
- export declare function parseCellulePersonne(cellule: string): PersonneNom | null;
37
- /** Extrait la date d'édition imprimée en pied de page ("Edition du JJ/MM/AAAA"). */
38
- export declare function extraireDateEdition(strings: string[]): Date | null;
36
+ export declare function parsePersonCell(cell: string): PersonName | null;
37
+ /** Extracts the edition date printed in the footer ("Edition du JJ/MM/AAAA"). */
38
+ export declare function extractEditionDate(strings: string[]): Date | null;
39
39
  /**
40
- * Reconstruit la liste {sénateur, collaborateur} à partir des éléments de texte positionnés.
41
- * Parcourt les pages puis les lignes de haut en bas ; mémorise le sénateur courant et lui
42
- * rattache les collaborateurs des lignes suivantes tant qu'aucun nouvel employeur n'apparaît.
40
+ * Reconstructs the {senator, collaborator} list from positioned text items.
41
+ * Iterates pages then lines top to bottom; tracks the current senator and
42
+ * attaches collaborators from subsequent lines until a new employer appears.
43
43
  */
44
- export declare function construireLignesCollaborateurs(items: PdfTextItem[]): LigneCollaborateur[];
44
+ export declare function buildCollaboratorRows(items: PdfTextItem[]): CollaboratorRow[];
@@ -1,22 +1,22 @@
1
1
  /**
2
- * Fonctions pures de parsing du PDF DRH des collaborateurs de sénateurs
2
+ * Pure functions for parsing the HR PDF listing senators' collaborators
3
3
  * (https://www.senat.fr/pubagas/liste_senateurs_collaborateurs.pdf).
4
4
  *
5
- * Le PDF est un tableau à deux colonnes par page :
6
- * - "Employeur" (le sénateur) à gauche, ex. "Mme AESCHLIMANN Marie-Do" ;
7
- * - "Nom Collaborateur" à droite, ex. "M. CHAREF Dahmane".
8
- * Une ligne sans employeur prolonge le sénateur précédent.
5
+ * The PDF is a two-column table per page:
6
+ * - "Employeur" (the senator) on the left, e.g. "Mme AESCHLIMANN Marie-Do" ;
7
+ * - "Nom Collaborateur" on the right, e.g. "M. CHAREF Dahmane".
8
+ * A line without an employer extends the previous senator.
9
9
  *
10
- * Pièges traités ici : aucun matricule dans le PDF, prénoms de sénateurs tronqués
11
- * ("Marie-Do"), noms composés ("DI FOLCO", "MAZENOT CHAPPUY"), accents.
10
+ * Edge cases handled here: no matricule in the PDF, truncated senator first names
11
+ * ("Marie-Do"), compound names ("DI FOLCO", "MAZENOT CHAPPUY"), accents.
12
12
  */
13
- // Bandes horizontales des deux colonnes (mesurées sur le PDF : employeur ≈ 147, collaborateur ≈ 285).
14
- const COLONNE_EMPLOYEUR = { min: 130, max: 225 };
15
- const COLONNE_COLLABORATEUR = { min: 255, max: 360 };
16
- const CIVILITES = ["Mme", "M.", "Mlle", "M"];
17
- // Particules nobiliaires/patronymiques en minuscules, qui font partie du nom quand elles le précèdent
18
- // (ex. "de CIDRAC", "de LA GONTRIE", "de LEGGE"). Le référentiel Sénat les conserve dans Nom_usuel.
19
- const PARTICULES = new Set([
13
+ // Horizontal bands of the two columns (measured on the PDF: employer ≈ 147, collaborator ≈ 285).
14
+ const EMPLOYER_COLUMN = { min: 130, max: 225 };
15
+ const COLLABORATOR_COLUMN = { min: 255, max: 360 };
16
+ const CIVILITIES = ["Mme", "M.", "Mlle", "M"];
17
+ // Lowercase nobiliary/patronymic particles that are part of the name when they precede it
18
+ // (e.g. "de CIDRAC", "de LA GONTRIE", "de LEGGE"). The Senate reference keeps them in Nom_usuel.
19
+ const PARTICLES = new Set([
20
20
  "de",
21
21
  "du",
22
22
  "des",
@@ -33,8 +33,8 @@ const PARTICULES = new Set([
33
33
  "del",
34
34
  "dos",
35
35
  ]);
36
- // Lignes d'en-tête / pied de page à ignorer.
37
- const LIGNES_IGNOREES = [
36
+ // Header / footer lines to ignore.
37
+ const IGNORED_LINES = [
38
38
  /liste des collaborateurs/i,
39
39
  /^employeur$/i,
40
40
  /^nom collaborateur$/i,
@@ -43,9 +43,9 @@ const LIGNES_IGNOREES = [
43
43
  /congé non rémunéré/i,
44
44
  /^-\s*\d+\s*-$/,
45
45
  ];
46
- /** Normalise un texte pour la comparaison : majuscules, sans accents, espaces compactés. */
47
- export function normaliserPourComparaison(valeur) {
48
- return valeur
46
+ /** Normalizes a string for comparison: uppercase, no accents, compacted spaces. */
47
+ export function normalizeForComparison(value) {
48
+ return value
49
49
  .normalize("NFD")
50
50
  .replace(/\p{Diacritic}/gu, "")
51
51
  .toUpperCase()
@@ -53,105 +53,105 @@ export function normaliserPourComparaison(valeur) {
53
53
  .replace(/\s+/g, " ")
54
54
  .trim();
55
55
  }
56
- function estIgnoree(str) {
56
+ function isIgnored(str) {
57
57
  const t = str.trim();
58
58
  if (!t)
59
59
  return true;
60
- return LIGNES_IGNOREES.some((re) => re.test(t));
60
+ return IGNORED_LINES.some((re) => re.test(t));
61
61
  }
62
62
  /**
63
- * Sépare une cellule "Civilité NOM(S) Prénom" en {civilite, nom, prenom}.
64
- * Les jetons en majuscules de tête forment le nom (gère les noms composés), le reste le prénom.
65
- * Renvoie null si la cellule n'est pas un nom de personne exploitable.
63
+ * Splits a "Civility LAST_NAME(S) FirstName" cell into {civilite, nom, prenom}.
64
+ * Uppercase head tokens form the name (handles compound names), the rest is the first name.
65
+ * Returns null if the cell is not a usable person name.
66
66
  */
67
- export function parseCellulePersonne(cellule) {
68
- const brut = cellule
67
+ export function parsePersonCell(cell) {
68
+ const raw = cell
69
69
  .replace(/\(\*\)/g, "")
70
70
  .replace(/\s+/g, " ")
71
71
  .trim();
72
- if (!brut)
72
+ if (!raw)
73
73
  return null;
74
74
  let civilite = "";
75
- let reste = brut;
76
- for (const civ of CIVILITES) {
77
- if (brut === civ)
75
+ let rest = raw;
76
+ for (const civ of CIVILITIES) {
77
+ if (raw === civ)
78
78
  return null;
79
- if (brut.startsWith(`${civ} `)) {
79
+ if (raw.startsWith(`${civ} `)) {
80
80
  civilite = civ;
81
- reste = brut.slice(civ.length + 1).trim();
81
+ rest = raw.slice(civ.length + 1).trim();
82
82
  break;
83
83
  }
84
84
  }
85
- if (!reste)
85
+ if (!rest)
86
86
  return null;
87
- const jetons = reste.split(" ");
88
- const estMajuscule = (jeton) => {
89
- const lettres = jeton.replace(/[^A-Za-zÀ-ÿ]/g, "");
90
- return lettres.length > 0 && lettres === lettres.toUpperCase();
87
+ const tokens = rest.split(" ");
88
+ const isUppercase = (token) => {
89
+ const letters = token.replace(/[^A-Za-zÀ-ÿ]/g, "");
90
+ return letters.length > 0 && letters === letters.toUpperCase();
91
91
  };
92
- const jetonsNom = [];
92
+ const nameTokens = [];
93
93
  let i = 0;
94
- // Particules de tête en minuscules (ex. "de", "de la") faisant partie du nom.
95
- while (i < jetons.length && PARTICULES.has(jetons[i].toLowerCase())) {
96
- jetonsNom.push(jetons[i]);
94
+ // Leading lowercase particles (e.g. "de", "de la") that are part of the name.
95
+ while (i < tokens.length && PARTICLES.has(tokens[i].toLowerCase())) {
96
+ nameTokens.push(tokens[i]);
97
97
  i++;
98
98
  }
99
- // Puis les jetons du nom en majuscules.
100
- while (i < jetons.length && estMajuscule(jetons[i])) {
101
- jetonsNom.push(jetons[i]);
99
+ // Then uppercase name tokens.
100
+ while (i < tokens.length && isUppercase(tokens[i])) {
101
+ nameTokens.push(tokens[i]);
102
102
  i++;
103
103
  }
104
- // Une particule seule sans nom en majuscules n'est pas un nom valide : on rétablit.
105
- if (jetonsNom.length > 0 && jetonsNom.every((j) => PARTICULES.has(j.toLowerCase()))) {
106
- jetonsNom.length = 0;
104
+ // A lone particle without an uppercase name is not valid: reset.
105
+ if (nameTokens.length > 0 && nameTokens.every((j) => PARTICLES.has(j.toLowerCase()))) {
106
+ nameTokens.length = 0;
107
107
  i = 0;
108
108
  }
109
- // Si aucun jeton majuscule (cas inattendu), on prend le premier comme nom.
110
- if (jetonsNom.length === 0 && jetons.length > 0) {
111
- jetonsNom.push(jetons[0]);
109
+ // If no uppercase token (unexpected case), take the first as name.
110
+ if (nameTokens.length === 0 && tokens.length > 0) {
111
+ nameTokens.push(tokens[0]);
112
112
  i = 1;
113
113
  }
114
- const nom = jetonsNom.join(" ");
115
- const prenom = jetons.slice(i).join(" ");
114
+ const nom = nameTokens.join(" ");
115
+ const prenom = tokens.slice(i).join(" ");
116
116
  if (!nom)
117
117
  return null;
118
118
  return { civilite, nom, prenom };
119
119
  }
120
- /** Extrait la date d'édition imprimée en pied de page ("Edition du JJ/MM/AAAA"). */
121
- export function extraireDateEdition(strings) {
120
+ /** Extracts the edition date printed in the footer ("Edition du JJ/MM/AAAA"). */
121
+ export function extractEditionDate(strings) {
122
122
  for (const s of strings) {
123
123
  const m = s.match(/Edition du\s+(\d{2})\/(\d{2})\/(\d{4})/i);
124
124
  if (m) {
125
- const [, jj, mm, aaaa] = m;
126
- return new Date(Date.UTC(Number(aaaa), Number(mm) - 1, Number(jj)));
125
+ const [, dd, mm, yyyy] = m;
126
+ return new Date(Date.UTC(Number(yyyy), Number(mm) - 1, Number(dd)));
127
127
  }
128
128
  }
129
129
  return null;
130
130
  }
131
131
  /**
132
- * Reconstruit la liste {sénateur, collaborateur} à partir des éléments de texte positionnés.
133
- * Parcourt les pages puis les lignes de haut en bas ; mémorise le sénateur courant et lui
134
- * rattache les collaborateurs des lignes suivantes tant qu'aucun nouvel employeur n'apparaît.
132
+ * Reconstructs the {senator, collaborator} list from positioned text items.
133
+ * Iterates pages then lines top to bottom; tracks the current senator and
134
+ * attaches collaborators from subsequent lines until a new employer appears.
135
135
  */
136
- export function construireLignesCollaborateurs(items) {
137
- const utiles = items.filter((it) => !estIgnoree(it.str));
138
- // Tri lecture : page croissante, puis y décroissant (hautbas), puis x croissant (gauchedroite).
139
- const tries = [...utiles].sort((a, b) => a.page - b.page || b.y - a.y || a.x - b.x);
140
- const lignes = [];
141
- let senateurCourant = null;
142
- for (const it of tries) {
143
- const dansEmployeur = it.x >= COLONNE_EMPLOYEUR.min && it.x <= COLONNE_EMPLOYEUR.max;
144
- const dansCollaborateur = it.x >= COLONNE_COLLABORATEUR.min && it.x <= COLONNE_COLLABORATEUR.max;
145
- if (dansEmployeur) {
146
- const personne = parseCellulePersonne(it.str);
147
- if (personne)
148
- senateurCourant = personne;
136
+ export function buildCollaboratorRows(items) {
137
+ const useful = items.filter((it) => !isIgnored(it.str));
138
+ // Reading order: increasing page, then decreasing y (topbottom), then increasing x (leftright).
139
+ const sorted = [...useful].sort((a, b) => a.page - b.page || b.y - a.y || a.x - b.x);
140
+ const rows = [];
141
+ let currentSenator = null;
142
+ for (const it of sorted) {
143
+ const inEmployer = it.x >= EMPLOYER_COLUMN.min && it.x <= EMPLOYER_COLUMN.max;
144
+ const inCollaborator = it.x >= COLLABORATOR_COLUMN.min && it.x <= COLLABORATOR_COLUMN.max;
145
+ if (inEmployer) {
146
+ const person = parsePersonCell(it.str);
147
+ if (person)
148
+ currentSenator = person;
149
149
  }
150
- else if (dansCollaborateur && senateurCourant) {
151
- const collaborateur = parseCellulePersonne(it.str);
150
+ else if (inCollaborator && currentSenator) {
151
+ const collaborateur = parsePersonCell(it.str);
152
152
  if (collaborateur)
153
- lignes.push({ senateur: senateurCourant, collaborateur });
153
+ rows.push({ senateur: currentSenator, collaborateur });
154
154
  }
155
155
  }
156
- return lignes;
156
+ return rows;
157
157
  }
@@ -0,0 +1,2 @@
1
+ import { type PlfCreditsSection } from "../../other_types/plf.js";
2
+ export declare function parsePlfCredits(document: Document): PlfCreditsSection[];
@@ -0,0 +1,171 @@
1
+ import { cleanText, classifyRow, detectEtatDSubSection, etatNumToBudgetType, etatNumToLibelle, isHeaderRow, parseLibelle, rowToMontants, SUBSECTION_DEFINITIONS, } from "./parser_utils.js";
2
+ // ---------------------------------------------------------------------------
3
+ // Table parsing
4
+ // ---------------------------------------------------------------------------
5
+ function parseCreditTable(table) {
6
+ const missions = [];
7
+ let currentMission = null;
8
+ let currentProgramme = null;
9
+ let tableTotal;
10
+ const rows = [...table.querySelectorAll("tr")];
11
+ // Skip header rows: the first row normally has colspan (the "(En euros)" row),
12
+ // and the second row has the column headers (bold, border-bottom).
13
+ let dataStartIndex = 0;
14
+ for (let i = 0; i < rows.length; i++) {
15
+ const tds = rows[i].querySelectorAll("td");
16
+ if (tds.length < 2) {
17
+ dataStartIndex = i + 1;
18
+ continue;
19
+ }
20
+ // Check if this is a header row: border-bottom on first td, or contains header text
21
+ const firstTdStyle = (tds[0]?.getAttribute("style") ?? "").replace(/[\n\t\r]/g, " ");
22
+ if (/border-bottom\s*:\s*1px/.test(firstTdStyle) ||
23
+ isHeaderRow(tds)) {
24
+ dataStartIndex = i + 1;
25
+ continue;
26
+ }
27
+ // If we have enough td cells, assume we're past headers
28
+ if (tds.length >= 3) {
29
+ dataStartIndex = i;
30
+ break;
31
+ }
32
+ dataStartIndex = i + 1;
33
+ }
34
+ for (let i = dataStartIndex; i < rows.length; i++) {
35
+ const tr = rows[i];
36
+ const tds = tr.querySelectorAll("td");
37
+ if (tds.length < 3)
38
+ continue;
39
+ const kind = classifyRow(tr);
40
+ const nameText = cleanText(tds[0].textContent ?? "");
41
+ if (!nameText)
42
+ continue;
43
+ if (kind === "total") {
44
+ tableTotal = rowToMontants(tr);
45
+ continue;
46
+ }
47
+ if (kind === "ligne") {
48
+ if (currentProgramme) {
49
+ const ligneLibelle = parseLibelle(tds[0].textContent ?? "");
50
+ if (ligneLibelle === undefined)
51
+ continue;
52
+ currentProgramme.lignes = currentProgramme.lignes || [];
53
+ currentProgramme.lignes.push({
54
+ libelle: ligneLibelle,
55
+ ...rowToMontants(tr),
56
+ });
57
+ }
58
+ continue;
59
+ }
60
+ if (kind === "mission") {
61
+ if (currentMission) {
62
+ missions.push(currentMission);
63
+ }
64
+ const libelle = parseLibelle(tds[0].textContent ?? "");
65
+ if (libelle === undefined) {
66
+ currentMission = null;
67
+ currentProgramme = null;
68
+ continue;
69
+ }
70
+ currentMission = {
71
+ budgetType: "budgetGeneral",
72
+ libelle,
73
+ ...rowToMontants(tr),
74
+ programmes: [],
75
+ };
76
+ currentProgramme = null;
77
+ continue;
78
+ }
79
+ // programme row
80
+ if (currentMission) {
81
+ const libelle = parseLibelle(tds[0].textContent ?? "");
82
+ if (libelle === undefined) {
83
+ currentProgramme = null;
84
+ continue;
85
+ }
86
+ currentProgramme = {
87
+ libelle,
88
+ ...rowToMontants(tr),
89
+ };
90
+ currentMission.programmes.push(currentProgramme);
91
+ }
92
+ else {
93
+ // If no mission is active, treat this as a mission with one programme
94
+ const libelle = parseLibelle(tds[0].textContent ?? "");
95
+ if (libelle === undefined)
96
+ continue;
97
+ currentMission = {
98
+ budgetType: "budgetGeneral",
99
+ libelle,
100
+ ...rowToMontants(tr),
101
+ programmes: [],
102
+ };
103
+ }
104
+ }
105
+ if (currentMission) {
106
+ missions.push(currentMission);
107
+ }
108
+ if (missions.length === 0)
109
+ return null;
110
+ return { missions, total: tableTotal };
111
+ }
112
+ // ---------------------------------------------------------------------------
113
+ // Main parser
114
+ // ---------------------------------------------------------------------------
115
+ export function parsePlfCredits(document) {
116
+ const body = document.querySelector("body");
117
+ if (!body)
118
+ return [];
119
+ const etatArticles = [...body.querySelectorAll('article[class="etat"]')];
120
+ if (etatArticles.length === 0)
121
+ return [];
122
+ const results = [];
123
+ for (const article of etatArticles) {
124
+ const numEl = article.querySelector(":scope > num");
125
+ if (!numEl)
126
+ continue;
127
+ const num = cleanText(numEl.textContent ?? "");
128
+ const budgetType = etatNumToBudgetType(num);
129
+ if (!budgetType)
130
+ continue;
131
+ // Find all tables in this etat article
132
+ const tables = [...article.querySelectorAll("table.alinea-table.etat-tableau")];
133
+ if (tables.length === 0)
134
+ continue;
135
+ // For État D, handle CAS/CCF sub-sections
136
+ if (budgetType === "comptesSpeciaux") {
137
+ let fallbackCasIndex = 0;
138
+ for (const table of tables) {
139
+ const subsection = detectEtatDSubSection(table) ??
140
+ (fallbackCasIndex === 0 ? "cas" : fallbackCasIndex === 1 ? "ccf" : undefined);
141
+ fallbackCasIndex++;
142
+ if (!subsection)
143
+ continue;
144
+ const parsed = parseCreditTable(table);
145
+ if (!parsed)
146
+ continue;
147
+ const def = SUBSECTION_DEFINITIONS[subsection];
148
+ results.push({
149
+ budgetType,
150
+ libelle: def.libelle,
151
+ missions: parsed.missions.map((m) => ({ ...m, budgetType })),
152
+ total: parsed.total,
153
+ });
154
+ }
155
+ }
156
+ else {
157
+ // État B or État C: single table
158
+ const parsed = parseCreditTable(tables[0]);
159
+ if (!parsed)
160
+ continue;
161
+ const libelle = etatNumToLibelle(num) ?? num;
162
+ results.push({
163
+ budgetType,
164
+ libelle,
165
+ missions: parsed.missions.map((m) => ({ ...m, budgetType })),
166
+ total: parsed.total,
167
+ });
168
+ }
169
+ }
170
+ return results;
171
+ }
@@ -0,0 +1 @@
1
+ export { parsePlfCredits } from "./credits.js";
@@ -0,0 +1 @@
1
+ export { parsePlfCredits } from "./credits.js";
@@ -0,0 +1,18 @@
1
+ import { type PlfBudgetType, type PlfLigne } from "../../other_types/plf.js";
2
+ export declare function cleanText(text: string): string;
3
+ export declare function hasBorderTop(style: string): boolean;
4
+ export declare function hasFontWeightBold(style: string): boolean;
5
+ export declare function hasFontStyleItalic(style: string): boolean;
6
+ export declare function normalizeAmount(text: string): number | undefined;
7
+ export declare function parseLibelle(text: string): string | undefined;
8
+ export declare function etatNumToBudgetType(num: string): PlfBudgetType | undefined;
9
+ export declare function etatNumToLibelle(num: string): string | undefined;
10
+ export type RowKind = "mission" | "programme" | "ligne" | "total" | "unknown";
11
+ export declare function classifyRow(tr: Element): RowKind;
12
+ export declare function rowToMontants(tr: Element): Omit<PlfLigne, "libelle">;
13
+ export declare function isHeaderRow(tds: Iterable<Element>): boolean;
14
+ export type EtatDSubSection = "cas" | "ccf";
15
+ export declare const SUBSECTION_DEFINITIONS: Record<EtatDSubSection, {
16
+ libelle: string;
17
+ }>;
18
+ export declare function detectEtatDSubSection(table: Element): EtatDSubSection | undefined;