@sylad/cadence 1.8.0 → 1.10.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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +27 -54
- package/agents/code-reviewer.md +2 -2
- package/agents/doc-keeper.md +19 -0
- package/agents/precheck-reader.md +1 -1
- package/agents/qa-reviewer.md +2 -2
- package/agents/reference-analyst.md +25 -0
- package/agents/reference-verifier.md +19 -0
- package/agents/spec-writer.md +1 -1
- package/agents/ux-reviewer.md +1 -1
- package/bin/cadence.js +9 -1
- package/dist/audit.js +16 -4
- package/dist/check.js +24 -2
- package/dist/cli.js +258 -30
- package/dist/config.js +101 -5
- package/dist/corrections.js +24 -2
- package/dist/deliver.js +125 -37
- package/dist/docsync.js +19 -3
- package/dist/git.js +16 -3
- package/dist/lead.js +45 -12
- package/dist/migrate.js +166 -0
- package/dist/orchestrate/briefs.js +46 -6
- package/dist/orchestrate/changelog-merge.js +94 -0
- package/dist/orchestrate/command.js +358 -41
- package/dist/orchestrate/cycle.js +922 -285
- package/dist/orchestrate/guard.js +2 -2
- package/dist/orchestrate/integrate.js +129 -0
- package/dist/orchestrate/launch.js +48 -6
- package/dist/orchestrate/mutex.js +30 -0
- package/dist/orchestrate/no-test.js +41 -0
- package/dist/orchestrate/pool.js +74 -36
- package/dist/orchestrate/registry.js +146 -14
- package/dist/orchestrate/result.js +1 -1
- package/dist/orchestrate/snapshot.js +54 -1
- package/dist/orchestrate/spec.js +89 -5
- package/dist/orchestrate/state.js +56 -15
- package/dist/orchestrate/table.js +74 -3
- package/dist/orchestrate/timeouts.js +40 -0
- package/dist/orchestrate/worktree.js +368 -0
- package/dist/perf.js +188 -25
- package/dist/plan.js +112 -7
- package/dist/proc.js +6 -0
- package/dist/ready-free.js +109 -0
- package/dist/ref/add.js +129 -0
- package/dist/ref/analyse.js +220 -0
- package/dist/ref/check.js +338 -0
- package/dist/ref/command.js +92 -0
- package/dist/ref/corpus-render.js +83 -0
- package/dist/ref/corpus.js +201 -0
- package/dist/ref/diff.js +262 -0
- package/dist/ref/evaluate.js +241 -0
- package/dist/ref/figures.js +252 -0
- package/dist/ref/measure.js +330 -0
- package/dist/ref/pdf.js +116 -0
- package/dist/ref/png.js +125 -0
- package/dist/ref/regard.js +102 -0
- package/dist/ref/session.js +34 -0
- package/dist/ref/store.js +40 -0
- package/dist/ref/tools.js +51 -0
- package/dist/resources.js +51 -0
- package/dist/session.js +5 -2
- package/dist/state.js +75 -7
- package/dist/testgate.js +15 -0
- package/docs/cadence-hud.md +12 -0
- package/docs/cycle-vague-dark.svg +1 -1
- package/docs/cycle-vague-light.svg +1 -1
- package/docs/deliver.md +76 -5
- package/docs/fonctionnement-wave-dark.svg +1 -1
- package/docs/fonctionnement-wave-light.svg +1 -1
- package/docs/fonctionnement.md +6 -6
- package/docs/lead.md +40 -13
- package/docs/news.md +30 -1
- package/docs/orchestrate.md +275 -33
- package/docs/overview.md +65 -1
- package/docs/perf.md +14 -8
- package/docs/raf.md +90 -11
- package/docs/ref.md +415 -0
- package/docs/releasing.md +3 -3
- package/docs/session.md +2 -2
- package/docs/skills.md +21 -2
- package/docs/spec.md +1 -1
- package/docs/stabilite.md +4 -4
- package/docs/whats-new.md +30 -1
- package/package.json +2 -1
- package/skills/deliver/SKILL.md +6 -1
- package/skills/lead/SKILL.md +28 -10
- package/skills/session-close/SKILL.md +1 -1
- package/skills/session-start/SKILL.md +2 -2
- package/templates/inventaire.json +96 -0
- package/templates/orchestrate/accept-back.md +1 -0
- package/templates/orchestrate/accept.md +1 -0
- package/templates/orchestrate/fiche.md +1 -0
- package/templates/orchestrate/fix-docs.md +18 -0
- package/templates/orchestrate/fix-minors.md +2 -1
- package/templates/orchestrate/fix.md +5 -2
- package/templates/orchestrate/implement.md +7 -4
- package/templates/orchestrate/maquette.md +2 -1
- package/templates/orchestrate/precheck.md +2 -1
- package/templates/orchestrate/review-recheck.md +2 -0
- package/templates/orchestrate/review-small.md +5 -1
- package/templates/orchestrate/review.md +2 -0
- package/templates/orchestrate/spec-back.md +2 -1
- package/templates/orchestrate/spec.md +4 -3
- package/templates/orchestrate/ux.md +3 -1
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { RafError } from '../plan.js';
|
|
4
|
+
import { TYPES, checkInventaire, readInventaire } from './check.js';
|
|
5
|
+
const norm = (v) => {
|
|
6
|
+
const s = String(v ?? '').normalize('NFC').toLowerCase().replace(/\s+/g, ' ').trim();
|
|
7
|
+
return /^-?\d+(\.\d+)?$/.test(s) ? String(Number(s)) : s;
|
|
8
|
+
};
|
|
9
|
+
/** Le libellé d'un `texte` est verbatim : seuls les blancs sont normalisés, la casse compte (`120 KT` n'est pas `120 kt`). */
|
|
10
|
+
const normTexte = (v) => String(v ?? '').normalize('NFC').replace(/\s+/g, ' ').trim();
|
|
11
|
+
const noms = (xs, cle) => (Array.isArray(xs) ? xs.map((x) => norm(x?.[cle])) : []);
|
|
12
|
+
/** Ce qui désigne un élément, indépendamment de son id, de sa zone et de sa confiance : deux lectures indépendantes numérotent à leur façon. */
|
|
13
|
+
export function identite(e, parId) {
|
|
14
|
+
const cible = (id) => {
|
|
15
|
+
const t = typeof id === 'string' ? parId.get(id) : undefined;
|
|
16
|
+
return t ? `${t.type}:${identite(t, new Map())}` : '?';
|
|
17
|
+
};
|
|
18
|
+
switch (e.type) {
|
|
19
|
+
case 'ecran':
|
|
20
|
+
return 'ecran';
|
|
21
|
+
case 'composant':
|
|
22
|
+
return `${norm(e.role)}|${norm(e.contenu)}`;
|
|
23
|
+
case 'etat':
|
|
24
|
+
return `${norm(e.nom)}>${cible(e.cible)}`;
|
|
25
|
+
case 'texte':
|
|
26
|
+
return normTexte(e.libelle);
|
|
27
|
+
case 'statut':
|
|
28
|
+
return norm(e.libelle);
|
|
29
|
+
case 'jeton':
|
|
30
|
+
return `${norm(e.categorie)}=${norm(e.valeur)}`;
|
|
31
|
+
case 'table':
|
|
32
|
+
return e.colonnes.map(norm).join('|');
|
|
33
|
+
case 'gabarit':
|
|
34
|
+
return norm(e.nom);
|
|
35
|
+
case 'schema':
|
|
36
|
+
return noms(e.noeuds, 'libelle').sort().join('|');
|
|
37
|
+
case 'lien':
|
|
38
|
+
return norm(e.figure);
|
|
39
|
+
default:
|
|
40
|
+
return norm(e.question);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/** Ce que l'élément affirme une fois retrouvé : une identité juste avec une affirmation fausse est un faux lu, pas un rappel. */
|
|
44
|
+
export function affirmation(e, parId) {
|
|
45
|
+
switch (e.type) {
|
|
46
|
+
case 'ecran':
|
|
47
|
+
return String(e.largeur_reference);
|
|
48
|
+
case 'texte':
|
|
49
|
+
return norm(e.role);
|
|
50
|
+
case 'table':
|
|
51
|
+
return JSON.stringify(e.lignes.map((l) => l.map(norm)));
|
|
52
|
+
case 'gabarit':
|
|
53
|
+
return JSON.stringify(e.champs.map((c) => [norm(c.nom), c.obligatoire, norm(c.format), c.min ?? null, c.max ?? null]));
|
|
54
|
+
case 'schema': {
|
|
55
|
+
const libelle = new Map(e.noeuds.map((n) => [n.id, norm(n.libelle)]));
|
|
56
|
+
return JSON.stringify(e.flux.map((f) => `${libelle.get(f.de)}>${libelle.get(f.vers)}:${norm(f.libelle)}:${f.direction}`).sort());
|
|
57
|
+
}
|
|
58
|
+
case 'lien':
|
|
59
|
+
return String(e.constat);
|
|
60
|
+
case 'statut':
|
|
61
|
+
return JSON.stringify(e.couvre.map((id) => {
|
|
62
|
+
const t = parId.get(id);
|
|
63
|
+
return t ? `${t.type}:${identite(t, new Map())}` : '?';
|
|
64
|
+
}).sort());
|
|
65
|
+
default:
|
|
66
|
+
return '';
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
const compteVide = () => ({ attendus: 0, lus: 0, justes: 0, sures: 0, faux_lu: 0 });
|
|
70
|
+
const ratio = (a, b) => (b === 0 ? null : a / b);
|
|
71
|
+
/** Apparie lus et attendus par type et identité, un attendu une seule fois ; les appariements à l'affirmation exacte passent d'abord. */
|
|
72
|
+
export function evaluerCas(nom, attendu, lecture) {
|
|
73
|
+
const types = {};
|
|
74
|
+
const compte = (t) => (types[t] ??= compteVide());
|
|
75
|
+
const parIdAttendu = new Map(attendu.elements.map((e) => [e.id, e]));
|
|
76
|
+
const lus = lecture ? lecture.elements : [];
|
|
77
|
+
const parIdLu = new Map(lus.map((e) => [e.id, e]));
|
|
78
|
+
const trouve = new Set();
|
|
79
|
+
const juste = new Set();
|
|
80
|
+
const justeAtt = new Set();
|
|
81
|
+
const libre = new Set(attendu.elements);
|
|
82
|
+
const cle = (e, par) => `${e.type}\u0000${identite(e, par)}`;
|
|
83
|
+
const apparier = (exact) => {
|
|
84
|
+
for (const lu of lus) {
|
|
85
|
+
if (juste.has(lu) || trouve.has(lu))
|
|
86
|
+
continue;
|
|
87
|
+
for (const att of libre) {
|
|
88
|
+
if (cle(att, parIdAttendu) !== cle(lu, parIdLu))
|
|
89
|
+
continue;
|
|
90
|
+
const pareil = affirmation(att, parIdAttendu) === affirmation(lu, parIdLu);
|
|
91
|
+
if (exact && !pareil)
|
|
92
|
+
continue;
|
|
93
|
+
libre.delete(att);
|
|
94
|
+
trouve.add(lu);
|
|
95
|
+
if (pareil) {
|
|
96
|
+
juste.add(lu);
|
|
97
|
+
justeAtt.add(att);
|
|
98
|
+
}
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
apparier(true);
|
|
104
|
+
apparier(false);
|
|
105
|
+
for (const e of attendu.elements)
|
|
106
|
+
compte(e.type).attendus++;
|
|
107
|
+
for (const lu of lus) {
|
|
108
|
+
const c = compte(lu.type);
|
|
109
|
+
c.lus++;
|
|
110
|
+
if (lu.confiance !== 'incertain')
|
|
111
|
+
c.sures++;
|
|
112
|
+
if (juste.has(lu))
|
|
113
|
+
c.justes++;
|
|
114
|
+
else if (lu.confiance !== 'incertain')
|
|
115
|
+
c.faux_lu++;
|
|
116
|
+
}
|
|
117
|
+
const parPiege = new Map();
|
|
118
|
+
for (const e of attendu.elements) {
|
|
119
|
+
if (typeof e.piege !== 'string')
|
|
120
|
+
continue;
|
|
121
|
+
const p = parPiege.get(e.piege) ?? { piege: e.piege, attendus: 0, trouves: 0 };
|
|
122
|
+
p.attendus++;
|
|
123
|
+
if (justeAtt.has(e))
|
|
124
|
+
p.trouves++;
|
|
125
|
+
parPiege.set(e.piege, p);
|
|
126
|
+
}
|
|
127
|
+
const tokens = typeof lecture?.usage?.tokens === 'number' && lecture.usage.tokens >= 0 ? lecture.usage.tokens : null;
|
|
128
|
+
return { cas: nom, lecture: lecture !== null, pages: Number.isInteger(attendu.pages) && attendu.pages > 0 ? attendu.pages : null, tokens, types, pieges: [...parPiege.values()] };
|
|
129
|
+
}
|
|
130
|
+
function taux(c) {
|
|
131
|
+
return { ...c, rappel: ratio(c.justes, c.attendus), precision: ratio(c.justes, c.lus), taux_faux_lu: ratio(c.faux_lu, c.sures) };
|
|
132
|
+
}
|
|
133
|
+
/** Additionne les cas : par type, au total, par piège (même nom d'un cas à l'autre) et en tokens par page (cas qui ont une mesure seulement). */
|
|
134
|
+
export function agreger(cas) {
|
|
135
|
+
const types = {};
|
|
136
|
+
const total = compteVide();
|
|
137
|
+
const pieges = new Map();
|
|
138
|
+
let tokens = null;
|
|
139
|
+
let pages = 0;
|
|
140
|
+
const exclus = [];
|
|
141
|
+
for (const r of cas) {
|
|
142
|
+
for (const [t, c] of Object.entries(r.types)) {
|
|
143
|
+
const a = (types[t] ??= compteVide());
|
|
144
|
+
for (const k of Object.keys(a)) {
|
|
145
|
+
a[k] += c[k];
|
|
146
|
+
total[k] += c[k];
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
for (const p of r.pieges) {
|
|
150
|
+
const a = pieges.get(p.piege) ?? { piege: p.piege, attendus: 0, trouves: 0 };
|
|
151
|
+
a.attendus += p.attendus;
|
|
152
|
+
a.trouves += p.trouves;
|
|
153
|
+
pieges.set(p.piege, a);
|
|
154
|
+
}
|
|
155
|
+
if (r.tokens !== null && r.pages === null)
|
|
156
|
+
exclus.push(r.cas);
|
|
157
|
+
else if (r.tokens !== null && r.pages !== null) {
|
|
158
|
+
tokens = (tokens ?? 0) + r.tokens;
|
|
159
|
+
pages += r.pages;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
const ordre = (t) => TYPES.indexOf(t);
|
|
163
|
+
const triee = Object.fromEntries(Object.entries(types).sort(([a], [b]) => ordre(a) - ordre(b)).map(([t, c]) => [t, taux(c)]));
|
|
164
|
+
return {
|
|
165
|
+
total: taux(total),
|
|
166
|
+
types: triee,
|
|
167
|
+
pieges: [...pieges.values()],
|
|
168
|
+
tokens: tokens === null || pages === 0 ? { total: null, pages: null, par_page: null, exclus } : { total: tokens, pages, par_page: tokens / pages, exclus },
|
|
169
|
+
cas,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
const pct = (x) => (x === null ? 'n/a' : `${Number((x * 100).toFixed(1))} %`);
|
|
173
|
+
export function formatEvaluation(ev) {
|
|
174
|
+
const t = ev.total;
|
|
175
|
+
const lignes = [
|
|
176
|
+
`${ev.cas.length} cas · ${t.attendus} éléments attendus · ${t.lus} lus`,
|
|
177
|
+
`rappel : ${pct(t.rappel)} (${t.justes}/${t.attendus}) · précision : ${pct(t.precision)} (${t.justes}/${t.lus})`,
|
|
178
|
+
`faux lu : ${pct(t.taux_faux_lu)} (${t.faux_lu}/${t.sures} sûrs)`,
|
|
179
|
+
ev.tokens.par_page === null ? 'tokens par page : non mesurés' : `tokens par page : ${Number(ev.tokens.par_page.toFixed(1))} (${ev.tokens.total} sur ${ev.tokens.pages} pages)`,
|
|
180
|
+
];
|
|
181
|
+
if (ev.tokens.exclus.length > 0)
|
|
182
|
+
lignes.push(`tokens par page : ${ev.tokens.exclus.length} cas sans \`pages\` exclus (${ev.tokens.exclus.join(', ')})`);
|
|
183
|
+
lignes.push('par type :');
|
|
184
|
+
for (const [type, c] of Object.entries(ev.types))
|
|
185
|
+
lignes.push(` ${type.padEnd(10)} rappel ${pct(c.rappel)} (${c.justes}/${c.attendus}) · précision ${pct(c.precision)} (${c.justes}/${c.lus}) · faux lu ${c.faux_lu}/${c.sures}`);
|
|
186
|
+
if (ev.pieges.length > 0) {
|
|
187
|
+
lignes.push('pièges :');
|
|
188
|
+
for (const p of ev.pieges)
|
|
189
|
+
lignes.push(` ${p.trouves === p.attendus ? '✓' : '✗'} ${p.piege} : ${p.trouves}/${p.attendus}`);
|
|
190
|
+
}
|
|
191
|
+
const sans = ev.cas.filter((c) => !c.lecture).map((c) => c.cas);
|
|
192
|
+
if (sans.length > 0)
|
|
193
|
+
lignes.push(`sans lecture : ${sans.join(', ')}`);
|
|
194
|
+
return lignes;
|
|
195
|
+
}
|
|
196
|
+
function charger(path, genres, role) {
|
|
197
|
+
const doc = readInventaire(path);
|
|
198
|
+
const problems = checkInventaire(doc);
|
|
199
|
+
if (problems.length > 0)
|
|
200
|
+
throw new RafError(`ref eval : ${role} ${path} n'est pas valide (${problems.length} écart(s), voir cadence ref check) : ${problems[0]}`);
|
|
201
|
+
if (!genres.includes(doc.genre))
|
|
202
|
+
throw new RafError(`ref eval : ${role} ${path} doit être ${genres.length === 1 ? `un ${genres[0]}` : `une lecture ou un inventaire`}, pas « ${doc.genre} »`);
|
|
203
|
+
return doc;
|
|
204
|
+
}
|
|
205
|
+
/** Les cas d'un chemin : un fichier est un cas, un dossier en a un par sous-dossier qui porte `fichier` (ordre alphabétique). */
|
|
206
|
+
function cheminsDeCas(chemin, fichier) {
|
|
207
|
+
if (!existsSync(chemin))
|
|
208
|
+
throw new RafError(`ref : fichier introuvable : ${chemin}`);
|
|
209
|
+
if (!statSync(chemin).isDirectory())
|
|
210
|
+
return [{ nom: 'document', fichier: chemin }];
|
|
211
|
+
return readdirSync(chemin)
|
|
212
|
+
.sort()
|
|
213
|
+
.filter((n) => existsSync(join(chemin, n, fichier)))
|
|
214
|
+
.map((n) => ({ nom: n, fichier: join(chemin, n, fichier) }));
|
|
215
|
+
}
|
|
216
|
+
/** `ref eval <attendu> <lecture>` : deux fichiers, ou deux dossiers (`<cas>/attendu.json`, `<cas>/lecture.json`). */
|
|
217
|
+
export function evaluer(attendu, lecture) {
|
|
218
|
+
const attendus = cheminsDeCas(attendu, 'attendu.json');
|
|
219
|
+
const lecturesTrouvees = cheminsDeCas(lecture, 'lecture.json');
|
|
220
|
+
const dossier = statSync(attendu).isDirectory();
|
|
221
|
+
// un fichier contre un dossier ne s'apparie à aucun cas : la lecture serait ignorée, la mesure fausse
|
|
222
|
+
if (dossier !== statSync(lecture).isDirectory()) {
|
|
223
|
+
throw new RafError(`ref eval : combinaisons acceptées : deux fichiers (attendu.json lecture.json) ou deux dossiers (<cas>/attendu.json, <cas>/lecture.json) ; reçu ${dossier ? 'un dossier et un fichier' : 'un fichier et un dossier'}`);
|
|
224
|
+
}
|
|
225
|
+
if (dossier && attendus.length === 0)
|
|
226
|
+
throw new RafError(`ref eval : aucun attendu.json dans les sous-dossiers de ${attendu}`);
|
|
227
|
+
const lectures = new Map(lecturesTrouvees.map((c) => [c.nom, c.fichier]));
|
|
228
|
+
const cas = attendus.map((a) => {
|
|
229
|
+
const att = charger(a.fichier, ['inventaire'], "l'attendu");
|
|
230
|
+
const fichier = dossier ? lectures.get(a.nom) : lecture;
|
|
231
|
+
if (fichier === undefined)
|
|
232
|
+
return evaluerCas(a.nom, att, null);
|
|
233
|
+
const lu = charger(fichier, ['lecture', 'inventaire'], 'la lecture');
|
|
234
|
+
if (lu.document.slug !== att.document.slug)
|
|
235
|
+
throw new RafError(`ref eval : le slug de la lecture (${lu.document.slug}) n'est pas celui de l'attendu (${att.document.slug})`);
|
|
236
|
+
if (lu.document.sha256 !== att.document.sha256)
|
|
237
|
+
throw new RafError(`ref eval : l'empreinte de la lecture n'est pas celle de l'attendu pour ${att.document.slug} : pas la même version du document`);
|
|
238
|
+
return evaluerCas(a.nom, att, lu);
|
|
239
|
+
});
|
|
240
|
+
return agreger(cas);
|
|
241
|
+
}
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
const WORDS = {
|
|
2
|
+
figure: { kind: 'figure', word: 'Figure' },
|
|
3
|
+
figures: { kind: 'figure', word: 'Figure' },
|
|
4
|
+
fig: { kind: 'figure', word: 'Figure' },
|
|
5
|
+
figs: { kind: 'figure', word: 'Figure' },
|
|
6
|
+
table: { kind: 'table', word: 'Table' },
|
|
7
|
+
tables: { kind: 'table', word: 'Table' },
|
|
8
|
+
tableau: { kind: 'table', word: 'Tableau' },
|
|
9
|
+
tableaux: { kind: 'table', word: 'Tableau' },
|
|
10
|
+
planche: { kind: 'planche', word: 'Planche' },
|
|
11
|
+
planches: { kind: 'planche', word: 'Planche' },
|
|
12
|
+
};
|
|
13
|
+
const NUMBER = String.raw `[A-Z]{0,2}[-.]?\d+(?:[.-](?:\d+|[A-Z](?![a-z])))*[a-z]?`;
|
|
14
|
+
/** Ce qui joint les deux bornes d'une plage : « 4 to 6 », « 4 à 6 », « 2–4 » (demi-cadratin collé aux numéros). Un tiret entouré d'espaces ou un cadratin (« 3—10 stations ») est une incise de prose. */
|
|
15
|
+
const RANGE = String.raw `(?:\s+(?:to|à)\s+|–)`;
|
|
16
|
+
/** Ce qui sépare deux numéros d'une énumération au pluriel : « 2, 3 », « 2 and 3 », plus les plages. */
|
|
17
|
+
const SEPARATOR = String.raw `(?:\s*,\s*(?:(?:and|et)\s+)?|\s+(?:and|et|&)\s+|${RANGE})`;
|
|
18
|
+
const REFERENCE = new RegExp(String.raw `\b(Figures?|Figs?|Tables?|Tableaux|Tableau|Planches?)\.?\s+(${NUMBER}(?:${SEPARATOR}${NUMBER})*)(?![\w])`, 'gi');
|
|
19
|
+
const PLURAL = /^(?:figures|figs|tables|tableaux|planches)$/i;
|
|
20
|
+
const NUMBER_ONLY = new RegExp(NUMBER, 'g');
|
|
21
|
+
/** Ce qui précède une référence qui n'en est pas une : « code figure 3 », « indicator figures 1 to 6 » des normes OMM sont des valeurs de code, pas des figures. */
|
|
22
|
+
const CODE_FIGURE = /\b(?:code|indicator)\s+$/i;
|
|
23
|
+
/** « code figure 17 » plus haut dans la même phrase (la phrase finit à un point, un point-virgule ou un deux-points suivi d'un blanc) : il porte une plage au demi-cadratin (« … over figures 20–49 »). */
|
|
24
|
+
const CODE_FIGURE_IN_SENTENCE = /\bcode\s+figures?\b/i;
|
|
25
|
+
const SENTENCE_END = /[.;:!?]\s/;
|
|
26
|
+
/** « Figure n » en début de ligne, puis le séparateur éventuel (tiret, deux-points, point) et le reste de la ligne. */
|
|
27
|
+
const CAPTION = new RegExp(String.raw `^(Figure|Fig|Table|Tableau|Planche)\.?\s+(${NUMBER})(?![\w])\s*([:.–—-])?\s*(.*)$`, 'i');
|
|
28
|
+
/** Le reste d'une ligne qui n'est qu'une parenthèse fermée : « (in accordance with 4.3.11) ». */
|
|
29
|
+
const WHOLE_PARENTHESIS = /^\([^()]*\)$/;
|
|
30
|
+
const DEICTIC = /\b(as shown (?:below|above)|shown (?:below|above)|illustrated (?:below|above)|ci-(?:dessous|dessus))\b/i;
|
|
31
|
+
/** Points de conduite d'un sommaire : « Figure 1 Titre ..... 12 ». */
|
|
32
|
+
const LEADER = /\.{4,}|(?:\.\s){4,}/;
|
|
33
|
+
/** Numéro de page en fin de titre d'un sommaire sans points de conduite : « Figure 1 Titre 12 » (le titre est « Titre 12 », le numéro de la figure n'en est pas un). */
|
|
34
|
+
const TRAILING_PAGE = /(?:^|\s)(\d{1,4})\s*$/;
|
|
35
|
+
/** Lignes courantes du document : en-têtes et pieds de page, c'est-à-dire les deux premières et les deux dernières lignes d'une page, chiffres mis à part, qui reviennent sur trois pages au moins. */
|
|
36
|
+
function runningLines(pages) {
|
|
37
|
+
const normal = (text) => text.trim().replace(/\d+/g, '#');
|
|
38
|
+
const pagesOf = new Map();
|
|
39
|
+
for (const p of pages)
|
|
40
|
+
for (const t of new Set([...p.lines.slice(0, 2), ...p.lines.slice(-2)].map((l) => normal(l.text))))
|
|
41
|
+
pagesOf.set(t, (pagesOf.get(t) ?? 0) + 1);
|
|
42
|
+
return (text) => (pagesOf.get(normal(text)) ?? 0) >= 3;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Dernière ligne de texte avant la ligne `i` de la page `pi`, sans les lignes courantes ni les lignes vides ; elle remonte sur la page
|
|
46
|
+
* qui précède immédiatement (numéro consécutif) : « code » peut finir une page, suivi de son pied de page et de l'en-tête de la suivante.
|
|
47
|
+
*/
|
|
48
|
+
function precedingText(pages, pi, i, running) {
|
|
49
|
+
let page = pages[pi];
|
|
50
|
+
for (let k = pi, j = i - 1;; j--) {
|
|
51
|
+
if (j < 0) {
|
|
52
|
+
const prev = pages[k - 1];
|
|
53
|
+
if (!prev || prev.n !== page.n - 1)
|
|
54
|
+
return '';
|
|
55
|
+
page = prev;
|
|
56
|
+
k--;
|
|
57
|
+
j = prev.lines.length - 1;
|
|
58
|
+
if (j < 0)
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
const text = page.lines[j].text;
|
|
62
|
+
if (text.trim() !== '' && !running(text))
|
|
63
|
+
return text;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const labelOf = (word, num) => {
|
|
67
|
+
const w = WORDS[word.toLowerCase()];
|
|
68
|
+
return { label: `${w.word} ${num}`, kind: w.kind };
|
|
69
|
+
};
|
|
70
|
+
const key = (label) => label.toLowerCase();
|
|
71
|
+
/**
|
|
72
|
+
* Une ligne a la forme d'une légende quand elle COMMENCE par « Figure n » / « Table n » et que ce qui suit n'est pas une phrase en
|
|
73
|
+
* cours. Après un séparateur (« Figure 1 — titre en minuscule », « Table 2: codes ») c'est une légende quelle que soit la casse ;
|
|
74
|
+
* sans séparateur, une minuscule (« Figure 3 shows … »), une virgule ou une parenthèse (« Figure 5, which … », « Figure 5 (page 4) and … »)
|
|
75
|
+
* en font une citation. Un séparateur suivi d'un titre vide (« figure 24. » en fin de phrase) n'est pas une légende non plus.
|
|
76
|
+
* Exception : une ligne faite UNIQUEMENT du numéro et d'une parenthèse fermée (« Table IV.3 (in accordance with 4.3.11) ») a la forme
|
|
77
|
+
* d'une légende, `parenthetical` ; `indexText` ne la retient que si aucune autre ligne ne l'est pour ce numéro.
|
|
78
|
+
* « Table 2 The WMO … » a la forme d'une légende : `indexText` la départage des autres lignes de même forme.
|
|
79
|
+
*/
|
|
80
|
+
function captionForm(text) {
|
|
81
|
+
const m = CAPTION.exec(text.trim());
|
|
82
|
+
if (!m)
|
|
83
|
+
return null;
|
|
84
|
+
const parenthetical = m[3] === undefined && WHOLE_PARENTHESIS.test(m[4]);
|
|
85
|
+
if (m[3] === undefined && !parenthetical && /^[a-zà-ÿ,;(\[]/.test(m[4]))
|
|
86
|
+
return null;
|
|
87
|
+
if (m[3] !== undefined && m[4] === '')
|
|
88
|
+
return null;
|
|
89
|
+
return { ...labelOf(m[1], m[2]), separated: m[3] !== undefined, parenthetical, title: m[4] };
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Lignes d'une page qui sont des entrées de sommaire (liste des figures, des tables). Une ligne à points de conduite (sur elle ou
|
|
93
|
+
* sur la suivante, titre coupé) en est une. Un numéro final ne suffit jamais : « Table 4.1. … presented in Chapter 4 » est une
|
|
94
|
+
* vraie légende ; il ne compte que dans une page de sommaire, où plus de la moitié des lignes (deux au moins) sont des entrées à
|
|
95
|
+
* points de conduite ou des lignes de forme de légende qui finissent par un numéro de page possible. Toutes les lignes d'une page
|
|
96
|
+
* de sommaire en sont.
|
|
97
|
+
*/
|
|
98
|
+
function tocLines(page, lastPage) {
|
|
99
|
+
const leaders = page.lines.map((l, i) => LEADER.test(l.text) || LEADER.test(page.lines[i + 1]?.text ?? ''));
|
|
100
|
+
const numbered = page.lines.map((l) => {
|
|
101
|
+
const number = TRAILING_PAGE.exec(captionForm(l.text)?.title ?? '')?.[1];
|
|
102
|
+
return number !== undefined && Number(number) <= lastPage;
|
|
103
|
+
});
|
|
104
|
+
const candidates = page.lines.filter((_, i) => leaders[i] || numbered[i]).length;
|
|
105
|
+
const contents = candidates >= 2 && candidates * 2 > page.lines.length;
|
|
106
|
+
return leaders.map((e) => e || contents);
|
|
107
|
+
}
|
|
108
|
+
const RANGE_ONLY = new RegExp(`^${RANGE}$`, 'i');
|
|
109
|
+
const named = (num) => ({ num, interior: false });
|
|
110
|
+
/** Un numéro divisé en préfixe (« 1.B. », « 3- », « A- », « A ») et chiffres finaux. */
|
|
111
|
+
const TRAILING_DIGITS = /^(.*\D)?(\d+)$/;
|
|
112
|
+
/**
|
|
113
|
+
* Numéros d'une plage : « 4 » et « 6 » donnent 4, 5, 6 (« 01 » et « 03 » donnent 01, 02, 03 : le zéro de tête de la première borne fixe la largeur) ; « 1.1 » et « 1.3 » donnent 1.1, 1.2, 1.3, de même « 3-1 »/« 3-3 » et « A1 »/« A3 ». Les bornes sont nommées, le milieu non.
|
|
114
|
+
* Sinon les bornes seules : préfixes différents, plage décroissante, 50 numéros ou plus. Une borne nue après une borne à préfixe
|
|
115
|
+
* (« 4.8–10 », « A3–1 ») n'est pas une figure à part : seule la première est gardée.
|
|
116
|
+
*/
|
|
117
|
+
function expand(from, to) {
|
|
118
|
+
const a = TRAILING_DIGITS.exec(from);
|
|
119
|
+
const b = TRAILING_DIGITS.exec(to);
|
|
120
|
+
if (b && b[1] === undefined && a && a[1] !== undefined)
|
|
121
|
+
return [named(from)];
|
|
122
|
+
if (!a || !b || (a[1] ?? '') !== (b[1] ?? ''))
|
|
123
|
+
return [named(from), named(to)];
|
|
124
|
+
const [lo, hi] = [Number(a[2]), Number(b[2])];
|
|
125
|
+
if (hi < lo || hi - lo >= 50)
|
|
126
|
+
return [named(from), named(to)];
|
|
127
|
+
return Array.from({ length: hi - lo + 1 }, (_, i) => ({ num: `${a[1] ?? ''}${String(lo + i).padStart(a[2].length, '0')}`, interior: i > 0 && i < hi - lo }));
|
|
128
|
+
}
|
|
129
|
+
/** Numéros nommés par une référence : le seul numéro au singulier ; au pluriel la liste, chaque plage (« 4 to 6 », « 2–4 ») développée quand ses bornes le permettent. */
|
|
130
|
+
function numbersOf(head, list) {
|
|
131
|
+
const found = [...list.matchAll(NUMBER_ONLY)];
|
|
132
|
+
if (!PLURAL.test(head))
|
|
133
|
+
return found.slice(0, 1).map((m) => named(m[0]));
|
|
134
|
+
const out = [];
|
|
135
|
+
found.forEach((m, i) => {
|
|
136
|
+
const prev = found[i - 1];
|
|
137
|
+
if (prev && RANGE_ONLY.test(list.slice(prev.index + prev[0].length, m.index)))
|
|
138
|
+
out.splice(out.length - 1, 1, ...expand(prev[0], m[0]));
|
|
139
|
+
else
|
|
140
|
+
out.push(named(m[0]));
|
|
141
|
+
});
|
|
142
|
+
return out;
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Légendes, citations et liens non résolus, d'après le texte seul ; les images viennent après (`attachRegions`).
|
|
146
|
+
* Une légende est une ligne de forme de légende (`captionForm`) qui n'est pas une entrée de sommaire ; une entrée de sommaire ne
|
|
147
|
+
* cite rien non plus. Quand plusieurs lignes ont la forme de la légende d'une même figure, on ne garde que celles qui ont un titre
|
|
148
|
+
* (« Figure 5 Jet speed » plutôt que « Figure 5 » seul), s'il y en a ; parmi elles la première qui est sur une page portant une image
|
|
149
|
+
* (`imagePages`), à défaut la première qui a un séparateur (« Table 3 — Mapping… » contre « Table 3 Identifies… »), à défaut la
|
|
150
|
+
* première du document.
|
|
151
|
+
*/
|
|
152
|
+
export function indexText(pages, imagePages = new Set()) {
|
|
153
|
+
const last = pages.reduce((m, p) => Math.max(m, p.n), 0);
|
|
154
|
+
const toc = new Map(pages.map((p) => [p.n, tocLines(p, last)]));
|
|
155
|
+
const candidates = new Map();
|
|
156
|
+
for (const p of pages) {
|
|
157
|
+
p.lines.forEach((l, i) => {
|
|
158
|
+
const c = toc.get(p.n)[i] ? null : captionForm(l.text);
|
|
159
|
+
if (!c)
|
|
160
|
+
return;
|
|
161
|
+
const list = candidates.get(key(c.label)) ?? [];
|
|
162
|
+
list.push({ ...c, page: p.n, line: i + 1, text: l.text.trim(), box: { x: l.x, y: l.y, l: l.l, h: l.h } });
|
|
163
|
+
candidates.set(key(c.label), list);
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
const chosen = [...candidates.values()]
|
|
167
|
+
.map((candidatesOfLabel) => {
|
|
168
|
+
// une ligne « Table n (…) » n'est une légende que si elle est la seule ligne de forme de légende de ce numéro
|
|
169
|
+
const all = candidatesOfLabel.filter((c) => !c.parenthetical || candidatesOfLabel.length === 1);
|
|
170
|
+
if (all.length === 0)
|
|
171
|
+
return null;
|
|
172
|
+
const list = all.some((c) => c.title !== '') ? all.filter((c) => c.title !== '') : all;
|
|
173
|
+
return list.find((c) => imagePages.has(c.page)) ?? list.find((c) => c.separated) ?? list[0];
|
|
174
|
+
})
|
|
175
|
+
.filter((c) => c !== null)
|
|
176
|
+
.sort((a, b) => a.page - b.page || a.line - b.line);
|
|
177
|
+
const figures = new Map(chosen.map((c) => [key(c.label), { label: c.label, kind: c.kind, page: c.page, line: c.line, caption: c.text, box: c.box, regions: [], citations: [] }]));
|
|
178
|
+
const captionLine = new Set(chosen.map((c) => `${c.page}:${c.line}`));
|
|
179
|
+
const unresolved = [];
|
|
180
|
+
const running = runningLines(pages);
|
|
181
|
+
for (const [pi, p] of pages.entries()) {
|
|
182
|
+
const captions = [...figures.values()].filter((f) => f.page === p.n);
|
|
183
|
+
p.lines.forEach((l, i) => {
|
|
184
|
+
if (toc.get(p.n)[i])
|
|
185
|
+
return;
|
|
186
|
+
const line = i + 1;
|
|
187
|
+
const own = captionLine.has(`${p.n}:${line}`);
|
|
188
|
+
const seen = new Set();
|
|
189
|
+
for (const m of l.text.matchAll(REFERENCE)) {
|
|
190
|
+
// la légende ne se cite pas elle-même
|
|
191
|
+
if (own && m.index === 0)
|
|
192
|
+
continue;
|
|
193
|
+
// « code » peut finir la ligne précédente : l'extrait PDF coupe les lignes, pas le texte
|
|
194
|
+
const head = l.text.slice(0, m.index);
|
|
195
|
+
const carried = head.trim() === '' ? `${precedingText(pages, pi, i, running)} ${head}` : head;
|
|
196
|
+
if (labelOf(m[1], '').kind === 'figure' && (CODE_FIGURE.test(carried) || (m[2].includes('–') && CODE_FIGURE_IN_SENTENCE.test(carried.split(SENTENCE_END).pop()))))
|
|
197
|
+
continue;
|
|
198
|
+
for (const { num, interior } of numbersOf(m[1], m[2])) {
|
|
199
|
+
const { label } = labelOf(m[1], num);
|
|
200
|
+
const fig = figures.get(key(label));
|
|
201
|
+
// le milieu d'une plage sans légende n'est pas un lien à résoudre (« figures 11–20 » n'en désigne aucune) et ne masque pas
|
|
202
|
+
// une citation explicite du même numéro plus loin sur la ligne
|
|
203
|
+
if (interior && !fig)
|
|
204
|
+
continue;
|
|
205
|
+
if (seen.has(key(label)))
|
|
206
|
+
continue;
|
|
207
|
+
seen.add(key(label));
|
|
208
|
+
if (fig)
|
|
209
|
+
fig.citations.push({ page: p.n, line, extrait: l.text.trim(), type: 'nommee' });
|
|
210
|
+
else
|
|
211
|
+
unresolved.push({ label, page: p.n, extrait: l.text.trim() });
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
const d = DEICTIC.exec(l.text);
|
|
215
|
+
if (!d)
|
|
216
|
+
return;
|
|
217
|
+
const nearest = captions.reduce((best, f) => (best === null || Math.abs(f.line - line) < Math.abs(best.line - line) ? f : best), null);
|
|
218
|
+
if (nearest)
|
|
219
|
+
nearest.citations.push({ page: p.n, line, extrait: l.text.trim(), type: 'designee' });
|
|
220
|
+
else
|
|
221
|
+
unresolved.push({ label: d[1].toLowerCase(), page: p.n, extrait: l.text.trim() });
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
// une figure que rien ne cite est aussi à résoudre
|
|
225
|
+
for (const f of figures.values())
|
|
226
|
+
if (f.citations.length === 0)
|
|
227
|
+
unresolved.push({ label: f.label, page: f.page, extrait: f.caption });
|
|
228
|
+
return { figures: [...figures.values()], unresolved };
|
|
229
|
+
}
|
|
230
|
+
/** Pages à rendre en plus de celles visées : celles des figures que les pages visées citent par leur nom. */
|
|
231
|
+
export function citedPages(index, wanted) {
|
|
232
|
+
const out = new Set();
|
|
233
|
+
for (const f of index.figures)
|
|
234
|
+
if (f.citations.some((c) => c.type === 'nommee' && wanted.has(c.page)))
|
|
235
|
+
out.add(f.page);
|
|
236
|
+
return out;
|
|
237
|
+
}
|
|
238
|
+
const centre = (b) => b.y + b.h / 2;
|
|
239
|
+
/** Relie chaque image à la légende de sa page la plus proche (en hauteur) ; une page sans région connue ne la donne qu'à sa seule légende. Une image sur une page sans légende est un lien non résolu. */
|
|
240
|
+
export function attachRegions(index, images) {
|
|
241
|
+
images.forEach((img, rank) => {
|
|
242
|
+
const captions = index.figures.filter((f) => f.page === img.page);
|
|
243
|
+
const target = img.box
|
|
244
|
+
? captions.reduce((best, f) => (best === null || Math.abs(centre(f.box) - centre(img.box)) < Math.abs(centre(best.box) - centre(img.box)) ? f : best), null)
|
|
245
|
+
: captions.length === 1
|
|
246
|
+
? captions[0]
|
|
247
|
+
: null;
|
|
248
|
+
target?.regions.push(rank);
|
|
249
|
+
if (captions.length === 0)
|
|
250
|
+
index.unresolved.push({ label: 'image sans légende', page: img.page, extrait: '' });
|
|
251
|
+
});
|
|
252
|
+
}
|