@sylad/cadence 1.8.0 → 1.9.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 +23 -7
- package/agents/code-reviewer.md +2 -2
- package/agents/doc-keeper.md +19 -0
- package/agents/qa-reviewer.md +2 -2
- package/agents/spec-writer.md +1 -1
- package/agents/ux-reviewer.md +1 -1
- package/bin/cadence.js +9 -1
- package/dist/audit.js +14 -4
- package/dist/check.js +6 -0
- package/dist/cli.js +235 -30
- package/dist/config.js +101 -5
- package/dist/corrections.js +23 -2
- package/dist/deliver.js +78 -21
- package/dist/docsync.js +19 -3
- package/dist/git.js +16 -3
- package/dist/lead.js +37 -12
- package/dist/migrate.js +166 -0
- package/dist/orchestrate/briefs.js +28 -6
- package/dist/orchestrate/command.js +303 -34
- package/dist/orchestrate/cycle.js +720 -273
- package/dist/orchestrate/guard.js +2 -2
- package/dist/orchestrate/integrate.js +85 -0
- package/dist/orchestrate/launch.js +37 -5
- package/dist/orchestrate/mutex.js +30 -0
- package/dist/orchestrate/pool.js +74 -36
- package/dist/orchestrate/registry.js +123 -14
- package/dist/orchestrate/result.js +1 -1
- package/dist/orchestrate/snapshot.js +25 -0
- package/dist/orchestrate/spec.js +89 -5
- package/dist/orchestrate/state.js +42 -6
- package/dist/orchestrate/table.js +61 -2
- package/dist/orchestrate/timeouts.js +40 -0
- package/dist/orchestrate/worktree.js +368 -0
- package/dist/perf.js +80 -19
- package/dist/plan.js +92 -6
- package/dist/ready-free.js +109 -0
- package/dist/ref/add.js +116 -0
- package/dist/ref/check.js +338 -0
- package/dist/ref/command.js +78 -0
- package/dist/ref/corpus-render.js +83 -0
- package/dist/ref/corpus.js +201 -0
- package/dist/ref/evaluate.js +241 -0
- package/dist/ref/figures.js +179 -0
- package/dist/ref/measure.js +330 -0
- package/dist/ref/pdf.js +104 -0
- package/dist/ref/png.js +125 -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 +60 -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 +58 -3
- package/docs/fonctionnement-wave-dark.svg +1 -1
- package/docs/fonctionnement-wave-light.svg +1 -1
- package/docs/fonctionnement.md +5 -5
- package/docs/lead.md +35 -12
- package/docs/news.md +24 -1
- package/docs/orchestrate.md +230 -19
- package/docs/perf.md +13 -8
- package/docs/raf.md +89 -11
- package/docs/ref.md +316 -0
- package/docs/session.md +2 -2
- package/docs/skills.md +9 -2
- package/docs/spec.md +1 -1
- package/docs/stabilite.md +4 -4
- package/docs/whats-new.md +2 -0
- package/package.json +2 -1
- package/skills/deliver/SKILL.md +6 -1
- package/skills/lead/SKILL.md +24 -6
- 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 +6 -3
- 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,201 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
/** Pixels CSS (96 dpi) → pixels de la page rendue à 200 dpi : le repère des zones d'un inventaire. */
|
|
3
|
+
const K = 200 / 96;
|
|
4
|
+
const zone = (b) => ({ x: Math.round(b.x * K), y: Math.round(b.y * K), l: Math.round(b.l * K), h: Math.round(b.h * K) });
|
|
5
|
+
const CSS = `@page{size:8.5in 11in;margin:0}html,body{margin:0}
|
|
6
|
+
.page{position:relative;width:816px;height:1056px;overflow:hidden;background:#fff;font-family:Arial,Helvetica,sans-serif;color:#111;page-break-after:always}
|
|
7
|
+
.page:last-child{page-break-after:auto}.b{position:absolute;box-sizing:border-box}
|
|
8
|
+
h1{margin:0;font-size:28px}p{margin:0;font-size:16px}table{border-collapse:collapse;font-size:15px}td,th{border:1px solid #333;padding:6px 10px;text-align:left}th{background:#e6e6e6}`;
|
|
9
|
+
const bloc = (b, contenu, style = '') => `<div class="b" style="left:${b.x}px;top:${b.y}px;width:${b.l}px;height:${b.h}px;${style}">${contenu}</div>`;
|
|
10
|
+
const assemble = (titre, pages) => `<!doctype html>\n<html lang="en"><head><meta charset="utf-8"><title>${titre}</title><style>${CSS}</style></head><body>\n${pages.map((p) => `<section class="page">\n${p}\n</section>`).join('\n')}\n</body></html>\n`;
|
|
11
|
+
/** Construit l'inventaire d'un cas : ids `corpus-<nom>#<type>-<n>` numérotés par type, zones en repère 200 dpi. */
|
|
12
|
+
class Inventaire {
|
|
13
|
+
nom;
|
|
14
|
+
n = new Map();
|
|
15
|
+
elements = [];
|
|
16
|
+
constructor(nom) {
|
|
17
|
+
this.nom = nom;
|
|
18
|
+
}
|
|
19
|
+
add(type, page, boite, champs, piege = false) {
|
|
20
|
+
const k = (this.n.get(type) ?? 0) + 1;
|
|
21
|
+
this.n.set(type, k);
|
|
22
|
+
const id = `corpus-${this.nom}#${type}-${k}`;
|
|
23
|
+
this.elements.push({ type, id, confiance: 'lu', source: boite ? { page, zone: zone(boite) } : { page }, ...champs, ...(piege ? { piege: this.nom } : {}) });
|
|
24
|
+
return id;
|
|
25
|
+
}
|
|
26
|
+
texte(page, boite, libelle, role, extra = {}, piege = false) {
|
|
27
|
+
return this.add('texte', page, boite, { libelle, role, ...extra }, piege);
|
|
28
|
+
}
|
|
29
|
+
/** Écran à la largeur de référence 1440 : la zone « page » couvre toute la page. */
|
|
30
|
+
ecran(page) {
|
|
31
|
+
return this.add('ecran', page, { x: 0, y: 0, l: 816, h: 1056 }, { largeur_reference: 1440, zones: [{ nom: 'page', x: 0, y: 0, l: 1440, h: 1863, pct: { x: 0, y: 0, l: 100, h: 100 } }] });
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
function fini(nom, titre, pages, inv, scan = false) {
|
|
35
|
+
const html = assemble(titre, pages);
|
|
36
|
+
const inventaire = {
|
|
37
|
+
version: 1,
|
|
38
|
+
genre: 'inventaire',
|
|
39
|
+
document: { slug: `corpus-${nom}`, sha256: createHash('sha256').update(html).digest('hex') },
|
|
40
|
+
pages: pages.length,
|
|
41
|
+
elements: inv.elements,
|
|
42
|
+
};
|
|
43
|
+
return { nom, html, pages: pages.length, scan, inventaire };
|
|
44
|
+
}
|
|
45
|
+
const TITRE = { x: 60, y: 50, l: 696, h: 40 };
|
|
46
|
+
const PARA = { x: 60, y: 110, l: 696, h: 24 };
|
|
47
|
+
const FIGURE = { x: 60, y: 170, l: 696, h: 300 };
|
|
48
|
+
const LEGENDE = { x: 60, y: 482, l: 696, h: 24 };
|
|
49
|
+
/** Texte ambigu que seule la figure lève : « à côté de la flèche » (tête ou queue ?) ; la figure met l'étiquette à la queue. */
|
|
50
|
+
function texteAmbigu() {
|
|
51
|
+
const nom = 'texte-ambigu-leve-par-figure';
|
|
52
|
+
const inv = new Inventaire(nom);
|
|
53
|
+
const legende = 'Figure 1 — Jet arrow with its speed label';
|
|
54
|
+
const phrase = 'Wind speed is written beside the arrow, in knots (Figure 1).';
|
|
55
|
+
const svg = `<svg width="696" height="300" viewBox="0 0 696 300" xmlns="http://www.w3.org/2000/svg" font-family="Arial,Helvetica,sans-serif">
|
|
56
|
+
<defs><marker id="t" markerWidth="12" markerHeight="12" refX="10" refY="6" orient="auto"><path d="M0,0 L12,6 L0,12 z" fill="#111"/></marker></defs>
|
|
57
|
+
<line x1="90" y1="160" x2="600" y2="160" stroke="#111" stroke-width="6" marker-end="url(#t)"/>
|
|
58
|
+
<text x="90" y="125" font-size="26" font-weight="bold">120 KT</text><text x="90" y="205" font-size="16">tail</text><text x="560" y="205" font-size="16">head</text></svg>`;
|
|
59
|
+
inv.texte(1, TITRE, 'Jet arrow annotation', 'titre');
|
|
60
|
+
inv.texte(1, PARA, phrase, 'paragraphe');
|
|
61
|
+
inv.texte(1, { x: 150, y: 270, l: 90, h: 34 }, '120 KT', 'etiquette-figure');
|
|
62
|
+
inv.texte(1, { x: 148, y: 358, l: 26, h: 24 }, 'tail', 'etiquette-figure');
|
|
63
|
+
inv.texte(1, { x: 618, y: 358, l: 40, h: 24 }, 'head', 'etiquette-figure');
|
|
64
|
+
inv.texte(1, LEGENDE, legende, 'legende');
|
|
65
|
+
inv.add('lien', 1, FIGURE, {
|
|
66
|
+
figure: legende,
|
|
67
|
+
passage: { page: 1, ligne: 2, extrait: 'Wind speed is written beside the arrow' },
|
|
68
|
+
constat: 'precise',
|
|
69
|
+
lecture_ambigue: 'À côté de la flèche : à la tête ou à la queue ?',
|
|
70
|
+
interpretation: "L'étiquette de vitesse se place au-dessus de la queue de la flèche",
|
|
71
|
+
raison: 'Dans la Figure 1, « 120 KT » est écrit au-dessus de la queue (tail), pas de la tête (head)',
|
|
72
|
+
}, true);
|
|
73
|
+
return fini(nom, 'Jet arrow annotation', [bloc(TITRE, '<h1>Jet arrow annotation</h1>') + bloc(PARA, `<p>${phrase}</p>`) + bloc(FIGURE, svg) + bloc(LEGENDE, `<p>${legende}</p>`)], inv);
|
|
74
|
+
}
|
|
75
|
+
/** Figure qui contredit le texte : le texte donne 80 kt, la figure trace le seuil à 100 kt. */
|
|
76
|
+
function figureContredit() {
|
|
77
|
+
const nom = 'figure-contredit-texte';
|
|
78
|
+
const inv = new Inventaire(nom);
|
|
79
|
+
const legende = 'Figure 1 — Reporting threshold';
|
|
80
|
+
const phrase = 'Turbulence is reported above 80 kt of jet speed (Figure 1).';
|
|
81
|
+
const svg = `<svg width="696" height="300" viewBox="0 0 696 300" xmlns="http://www.w3.org/2000/svg" font-family="Arial,Helvetica,sans-serif">
|
|
82
|
+
<rect x="90" y="190" width="420" height="60" fill="#9bb7d4"/><rect x="90" y="110" width="420" height="4" fill="#c0392b"/>
|
|
83
|
+
<text x="520" y="120" font-size="22" font-weight="bold">100 kt</text><text x="90" y="280" font-size="18">Jet speed</text></svg>`;
|
|
84
|
+
inv.texte(1, TITRE, 'Turbulence reporting', 'titre');
|
|
85
|
+
inv.texte(1, PARA, phrase, 'paragraphe');
|
|
86
|
+
inv.texte(1, { x: 578, y: 268, l: 68, h: 30 }, '100 kt', 'etiquette-figure');
|
|
87
|
+
inv.texte(1, { x: 148, y: 432, l: 84, h: 26 }, 'Jet speed', 'etiquette-figure');
|
|
88
|
+
inv.texte(1, LEGENDE, legende, 'legende');
|
|
89
|
+
inv.add('lien', 1, FIGURE, {
|
|
90
|
+
figure: legende,
|
|
91
|
+
passage: { page: 1, ligne: 2, extrait: 'Turbulence is reported above 80 kt' },
|
|
92
|
+
constat: 'contredit',
|
|
93
|
+
enonces: ['Texte : la turbulence est signalée au-dessus de 80 kt', 'Figure 1 : le seuil est tracé à 100 kt'],
|
|
94
|
+
}, true);
|
|
95
|
+
return fini(nom, 'Turbulence reporting', [bloc(TITRE, '<h1>Turbulence reporting</h1>') + bloc(PARA, `<p>${phrase}</p>`) + bloc(FIGURE, svg) + bloc(LEGENDE, `<p>${legende}</p>`)], inv);
|
|
96
|
+
}
|
|
97
|
+
/** Table en image : dessinée en HTML puis rastérisée au rendu, absente de la couche texte du PDF. */
|
|
98
|
+
function tableEnImage() {
|
|
99
|
+
const nom = 'table-en-image';
|
|
100
|
+
const inv = new Inventaire(nom);
|
|
101
|
+
const colonnes = ['Code', 'Meaning', 'Oktas'];
|
|
102
|
+
const lignes = [['0', 'Clear', '0'], ['1', 'Few', '1-2'], ['3', 'Scattered', '3-4'], ['5', 'Broken', '5-7'], ['8', 'Overcast', '8']];
|
|
103
|
+
const legende = 'Table 1 — Cloud amount';
|
|
104
|
+
const phrase = 'The codes are listed in Table 1.';
|
|
105
|
+
const zoneTable = { x: 60, y: 190, l: 400, h: 230 };
|
|
106
|
+
const html = `<table class="raster"><tr>${colonnes.map((c) => `<th>${c}</th>`).join('')}</tr>${lignes.map((l) => `<tr>${l.map((c) => `<td>${c}</td>`).join('')}</tr>`).join('')}</table>`;
|
|
107
|
+
inv.texte(1, TITRE, 'Cloud amount codes', 'titre');
|
|
108
|
+
inv.texte(1, PARA, phrase, 'paragraphe');
|
|
109
|
+
inv.texte(1, { x: 60, y: 156, l: 300, h: 24 }, legende, 'legende');
|
|
110
|
+
inv.add('table', 1, zoneTable, { colonnes, lignes }, true);
|
|
111
|
+
return fini(nom, 'Cloud amount codes', [bloc(TITRE, '<h1>Cloud amount codes</h1>') + bloc(PARA, `<p>${phrase}</p>`) + bloc({ x: 60, y: 156, l: 300, h: 24 }, `<p>${legende}</p>`) + bloc(zoneTable, html)], inv);
|
|
112
|
+
}
|
|
113
|
+
/** Gabarit de type BUFR (section 1) : champs ordonnés, obligatoires ou facultatifs, format et bornes. */
|
|
114
|
+
function gabaritBufr() {
|
|
115
|
+
const nom = 'gabarit-type-bufr';
|
|
116
|
+
const inv = new Inventaire(nom);
|
|
117
|
+
const champs = [
|
|
118
|
+
{ octets: '1-3', nom: 'Length of section', format: 'uint24', statut: 'Mandatory', min: 0, max: 16777215 },
|
|
119
|
+
{ octets: '4', nom: 'Master table', format: 'uint8', statut: 'Mandatory', min: 0, max: 255 },
|
|
120
|
+
{ octets: '5-6', nom: 'Originating centre', format: 'uint16', statut: 'Mandatory', min: 0, max: 65535 },
|
|
121
|
+
{ octets: '7', nom: 'Update sequence', format: 'uint8', statut: 'Optional', min: 0, max: 255 },
|
|
122
|
+
{ octets: '8', nom: 'Optional section flag', format: 'bit', statut: 'Optional', min: 0, max: 1 },
|
|
123
|
+
];
|
|
124
|
+
const zoneTable = { x: 60, y: 130, l: 696, h: 250 };
|
|
125
|
+
const rangs = champs.map((c) => `<tr><td>${c.octets}</td><td>${c.nom}</td><td>${c.format}</td><td>${c.statut}</td><td>${c.min}-${c.max}</td></tr>`).join('');
|
|
126
|
+
const html = `<table><tr><th>Octet</th><th>Field</th><th>Format</th><th>Status</th><th>Range</th></tr>${rangs}</table>`;
|
|
127
|
+
inv.texte(1, TITRE, 'BUFR section 1', 'titre');
|
|
128
|
+
inv.add('gabarit', 1, zoneTable, { nom: 'BUFR section 1', champs: champs.map((c) => ({ nom: c.nom, obligatoire: c.statut === 'Mandatory', format: c.format, min: c.min, max: c.max })) }, true);
|
|
129
|
+
return fini(nom, 'BUFR section 1', [bloc(TITRE, '<h1>BUFR section 1</h1>') + bloc(zoneTable, html)], inv);
|
|
130
|
+
}
|
|
131
|
+
/** Contrôle translucide sur fond chargé : un bouton blanc à 25 % d'opacité sur une carte à bandes, à côté d'un bouton opaque. */
|
|
132
|
+
function controleTranslucide() {
|
|
133
|
+
const nom = 'controle-translucide';
|
|
134
|
+
const inv = new Inventaire(nom);
|
|
135
|
+
const fond = 'background:linear-gradient(135deg,#0b3d91 0%,#2e8b57 50%,#f2c14e 100%),repeating-linear-gradient(45deg,#ffffff 0 10px,#000000 10px 20px);background-blend-mode:multiply;';
|
|
136
|
+
const layers = { x: 300, y: 400, l: 160, h: 44 };
|
|
137
|
+
const zoom = { x: 500, y: 400, l: 120, h: 44 };
|
|
138
|
+
const ecran = inv.ecran(1);
|
|
139
|
+
inv.add('composant', 1, layers, { role: 'bouton', position: { x: Math.round(layers.x * K), y: Math.round(layers.y * K) }, taille: { l: Math.round(layers.l * K), h: Math.round(layers.h * K) }, contenu: 'Layers', ecran });
|
|
140
|
+
inv.add('composant', 1, zoom, { role: 'bouton', position: { x: Math.round(zoom.x * K), y: Math.round(zoom.y * K) }, taille: { l: Math.round(zoom.l * K), h: Math.round(zoom.h * K) }, contenu: 'Zoom', ecran });
|
|
141
|
+
inv.texte(1, { x: 340, y: 410, l: 90, h: 24 }, 'Layers', 'bouton');
|
|
142
|
+
inv.texte(1, { x: 535, y: 410, l: 60, h: 24 }, 'Zoom', 'bouton');
|
|
143
|
+
inv.add('jeton', 1, layers, { categorie: 'opacite', valeur: '0.25', point: { x: Math.round((layers.x + 10) * K), y: Math.round((layers.y + 10) * K) } }, true);
|
|
144
|
+
inv.add('jeton', 1, zoom, { categorie: 'couleur', valeur: '#1e3a8a', point: { x: Math.round((zoom.x + 10) * K), y: Math.round((zoom.y + 10) * K) } });
|
|
145
|
+
const bouton = (b, texte, style) => bloc(b, `<span style="font-size:18px;color:#fff;line-height:44px">${texte}</span>`, `text-align:center;border-radius:6px;${style}`);
|
|
146
|
+
const page = bloc({ x: 0, y: 0, l: 816, h: 1056 }, '', fond) + bouton(layers, 'Layers', 'background:rgba(255, 255, 255, 0.25);') + bouton(zoom, 'Zoom', 'background:#1e3a8a;');
|
|
147
|
+
return fini(nom, 'Map controls', [page], inv);
|
|
148
|
+
}
|
|
149
|
+
/** Page « Proposal » parmi des pages exigées : les exigences (page 1) et une maquette de panneau marquée Proposal (page 2). */
|
|
150
|
+
function pageProposal() {
|
|
151
|
+
const nom = 'page-proposal';
|
|
152
|
+
const inv = new Inventaire(nom);
|
|
153
|
+
const exigences = ['REQ-1 The map shall show the layer panel.', 'REQ-2 The panel shall list every active layer.'];
|
|
154
|
+
const panneau = { x: 60, y: 160, l: 300, h: 420 };
|
|
155
|
+
const badge = { x: 600, y: 50, l: 150, h: 36 };
|
|
156
|
+
const legendePanneau = 'Figure 1 — Layer panel';
|
|
157
|
+
const LEGENDE_PANNEAU = { x: 60, y: 592, l: 300, h: 24 };
|
|
158
|
+
inv.texte(1, TITRE, 'Requirements', 'titre');
|
|
159
|
+
exigences.forEach((t, i) => inv.texte(1, { x: 60, y: 110 + i * 40, l: 696, h: 24 }, t, 'exigence'));
|
|
160
|
+
const ecran = inv.ecran(2);
|
|
161
|
+
inv.texte(2, { x: 60, y: 50, l: 400, h: 40 }, 'Layer panel mock-up', 'titre');
|
|
162
|
+
const comp = inv.add('composant', 2, panneau, { role: 'panneau', position: { x: Math.round(panneau.x * K), y: Math.round(panneau.y * K) }, taille: { l: Math.round(panneau.l * K), h: Math.round(panneau.h * K) }, contenu: 'Layers', ecran });
|
|
163
|
+
inv.texte(2, { x: 80, y: 176, l: 100, h: 26 }, 'Layers', 'titre-panneau');
|
|
164
|
+
// deux lignes du panneau : « Wind » dessinée sélectionnée (fond bleu, bordure), « Waves » dessinée grisée (désactivée)
|
|
165
|
+
const wind = { x: 80, y: 230, l: 240, h: 36 };
|
|
166
|
+
const waves = { x: 80, y: 280, l: 240, h: 36 };
|
|
167
|
+
const ligne = (b, contenu) => inv.add('composant', 2, b, { role: 'case_a_cocher', position: { x: Math.round(b.x * K), y: Math.round(b.y * K) }, taille: { l: Math.round(b.l * K), h: Math.round(b.h * K) }, contenu, ecran });
|
|
168
|
+
const compWind = ligne(wind, 'Wind');
|
|
169
|
+
const compWaves = ligne(waves, 'Waves');
|
|
170
|
+
inv.add('etat', 2, wind, { nom: 'selectionne', cible: compWind });
|
|
171
|
+
inv.add('etat', 2, waves, { nom: 'desactive', cible: compWaves });
|
|
172
|
+
// convention du corpus : tout libellé visible d'un composant est attendu aussi comme `texte`, avec le rôle du composant
|
|
173
|
+
inv.texte(2, { x: 90, y: 234, l: 70, h: 28 }, 'Wind', 'case_a_cocher');
|
|
174
|
+
inv.texte(2, { x: 90, y: 284, l: 70, h: 28 }, 'Waves', 'case_a_cocher');
|
|
175
|
+
inv.texte(2, LEGENDE_PANNEAU, legendePanneau, 'legende');
|
|
176
|
+
inv.texte(2, { x: 625, y: 54, l: 100, h: 28 }, 'Proposal', 'badge');
|
|
177
|
+
inv.add('lien', 2, panneau, { figure: legendePanneau, passage: { page: 1, ligne: 3, extrait: 'REQ-2 The panel shall list every active layer.' }, constat: 'confirme' });
|
|
178
|
+
inv.add('statut', 2, badge, { libelle: 'Proposal', couvre: [comp] }, true);
|
|
179
|
+
const page1 = bloc(TITRE, '<h1>Requirements</h1>') + exigences.map((t, i) => bloc({ x: 60, y: 110 + i * 40, l: 696, h: 24 }, `<p>${t}</p>`)).join('');
|
|
180
|
+
const page2 = bloc({ x: 60, y: 50, l: 400, h: 40 }, '<h1>Layer panel mock-up</h1>') +
|
|
181
|
+
bloc(badge, '<span style="font-size:18px;font-weight:bold;color:#fff;line-height:36px">Proposal</span>', 'background:#e67e22;border-radius:18px;text-align:center') +
|
|
182
|
+
bloc(panneau, '<p style="font-size:20px;font-weight:bold;margin:16px 20px">Layers</p>', 'background:#f4f4f4;border:2px solid #333') +
|
|
183
|
+
bloc(wind, '<p style="font-size:16px;line-height:32px;padding-left:12px;color:#0b2a66">Wind</p>', 'background:#cfe3ff;border:2px solid #1e5fd0') +
|
|
184
|
+
bloc(waves, '<p style="font-size:16px;line-height:32px;padding-left:12px;color:#999">Waves</p>', 'background:#e6e6e6;border:2px solid #cfcfcf') +
|
|
185
|
+
bloc(LEGENDE_PANNEAU, `<p>${legendePanneau}</p>`);
|
|
186
|
+
return fini(nom, 'Layer panel', [page1, page2], inv);
|
|
187
|
+
}
|
|
188
|
+
/** Version scannée : une fiche de station dessinée puis rastérisée, bruitée et légèrement penchée au rendu ; texte attendu issu d'un OCR. */
|
|
189
|
+
function scanBruite() {
|
|
190
|
+
const nom = 'scan-bruite';
|
|
191
|
+
const inv = new Inventaire(nom);
|
|
192
|
+
const lignes = ['Station: LFPG', 'Visibility: 9999 m', 'Wind: 240/12 KT'];
|
|
193
|
+
inv.texte(1, TITRE, 'Station report', 'titre', { ocr: true }, true);
|
|
194
|
+
lignes.forEach((t, i) => inv.texte(1, { x: 60, y: 130 + i * 50, l: 400, h: 30 }, t, 'ligne', { ocr: true }, true));
|
|
195
|
+
const page = bloc(TITRE, '<h1>Station report</h1>') + lignes.map((t, i) => bloc({ x: 60, y: 130 + i * 50, l: 400, h: 30 }, `<p style="font-size:22px">${t}</p>`)).join('');
|
|
196
|
+
return fini(nom, 'Station report', [page], inv, true);
|
|
197
|
+
}
|
|
198
|
+
/** Le corpus synthétique à vérité connue : un cas par piège de la conception, dans l'ordre (design « Évaluation »). */
|
|
199
|
+
export function buildCorpus() {
|
|
200
|
+
return [texteAmbigu(), figureContredit(), tableEnImage(), gabaritBufr(), controleTranslucide(), pageProposal(), scanBruite()];
|
|
201
|
+
}
|
|
@@ -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
|
+
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
|
+
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,179 @@
|
|
|
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 sépare deux numéros d'une énumération au pluriel : « 2, 3 », « 2 and 3 », « 4 to 6 ». */
|
|
15
|
+
const SEPARATOR = String.raw `(?:\s*,\s*(?:(?:and|et)\s+)?|\s+(?:and|et|to|à|&)\s+)`;
|
|
16
|
+
const REFERENCE = new RegExp(String.raw `\b(Figures?|Figs?|Tables?|Tableaux|Tableau|Planches?)\.?\s+(${NUMBER}(?:${SEPARATOR}${NUMBER})*)(?![\w])`, 'gi');
|
|
17
|
+
const PLURAL = /^(?:figures|figs|tables|tableaux|planches)$/i;
|
|
18
|
+
const NUMBER_ONLY = new RegExp(NUMBER, 'g');
|
|
19
|
+
/** « Figure n » en début de ligne, puis le séparateur éventuel (tiret, deux-points, point) et le reste de la ligne. */
|
|
20
|
+
const CAPTION = new RegExp(String.raw `^(Figure|Fig|Table|Tableau|Planche)\.?\s+(${NUMBER})(?![\w])\s*([:.–—-])?\s*(.*)$`, 'i');
|
|
21
|
+
/** Le reste d'une ligne qui n'est qu'une parenthèse fermée : « (in accordance with 4.3.11) ». */
|
|
22
|
+
const WHOLE_PARENTHESIS = /^\([^()]*\)$/;
|
|
23
|
+
const DEICTIC = /\b(as shown (?:below|above)|shown (?:below|above)|illustrated (?:below|above)|ci-(?:dessous|dessus))\b/i;
|
|
24
|
+
/** Points de conduite d'un sommaire : « Figure 1 Titre ..... 12 ». */
|
|
25
|
+
const LEADER = /\.{4,}|(?:\.\s){4,}/;
|
|
26
|
+
/** 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). */
|
|
27
|
+
const TRAILING_PAGE = /(?:^|\s)(\d{1,4})\s*$/;
|
|
28
|
+
const labelOf = (word, num) => {
|
|
29
|
+
const w = WORDS[word.toLowerCase()];
|
|
30
|
+
return { label: `${w.word} ${num}`, kind: w.kind };
|
|
31
|
+
};
|
|
32
|
+
const key = (label) => label.toLowerCase();
|
|
33
|
+
/**
|
|
34
|
+
* 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
|
|
35
|
+
* cours. Après un séparateur (« Figure 1 — titre en minuscule », « Table 2: codes ») c'est une légende quelle que soit la casse ;
|
|
36
|
+
* sans séparateur, une minuscule (« Figure 3 shows … »), une virgule ou une parenthèse (« Figure 5, which … », « Figure 5 (page 4) and … »)
|
|
37
|
+
* 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.
|
|
38
|
+
* 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
|
|
39
|
+
* d'une légende, `parenthetical` ; `indexText` ne la retient que si aucune autre ligne ne l'est pour ce numéro.
|
|
40
|
+
* « Table 2 The WMO … » a la forme d'une légende : `indexText` la départage des autres lignes de même forme.
|
|
41
|
+
*/
|
|
42
|
+
function captionForm(text) {
|
|
43
|
+
const m = CAPTION.exec(text.trim());
|
|
44
|
+
if (!m)
|
|
45
|
+
return null;
|
|
46
|
+
const parenthetical = m[3] === undefined && WHOLE_PARENTHESIS.test(m[4]);
|
|
47
|
+
if (m[3] === undefined && !parenthetical && /^[a-zà-ÿ,;(\[]/.test(m[4]))
|
|
48
|
+
return null;
|
|
49
|
+
if (m[3] !== undefined && m[4] === '')
|
|
50
|
+
return null;
|
|
51
|
+
return { ...labelOf(m[1], m[2]), separated: m[3] !== undefined, parenthetical, title: m[4] };
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Lignes d'une page qui sont des entrées de sommaire (liste des figures, des tables). Une ligne à points de conduite (sur elle ou
|
|
55
|
+
* sur la suivante, titre coupé) en est une. Un numéro final ne suffit jamais : « Table 4.1. … presented in Chapter 4 » est une
|
|
56
|
+
* 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 à
|
|
57
|
+
* 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
|
|
58
|
+
* de sommaire en sont.
|
|
59
|
+
*/
|
|
60
|
+
function tocLines(page, lastPage) {
|
|
61
|
+
const leaders = page.lines.map((l, i) => LEADER.test(l.text) || LEADER.test(page.lines[i + 1]?.text ?? ''));
|
|
62
|
+
const numbered = page.lines.map((l) => {
|
|
63
|
+
const number = TRAILING_PAGE.exec(captionForm(l.text)?.title ?? '')?.[1];
|
|
64
|
+
return number !== undefined && Number(number) <= lastPage;
|
|
65
|
+
});
|
|
66
|
+
const candidates = page.lines.filter((_, i) => leaders[i] || numbered[i]).length;
|
|
67
|
+
const contents = candidates >= 2 && candidates * 2 > page.lines.length;
|
|
68
|
+
return leaders.map((e) => e || contents);
|
|
69
|
+
}
|
|
70
|
+
/** Numéros nommés par une référence : le seul numéro au singulier ; au pluriel la liste, et « 4 to 6 » donne 4, 5, 6 quand ce sont des entiers. */
|
|
71
|
+
function numbersOf(head, list) {
|
|
72
|
+
const nums = list.match(NUMBER_ONLY) ?? [];
|
|
73
|
+
if (!PLURAL.test(head))
|
|
74
|
+
return nums.slice(0, 1);
|
|
75
|
+
const range = /^(\d+)\s+(?:to|à)\s+(\d+)$/i.exec(list.trim());
|
|
76
|
+
if (range && Number(range[2]) >= Number(range[1]) && Number(range[2]) - Number(range[1]) < 50) {
|
|
77
|
+
return Array.from({ length: Number(range[2]) - Number(range[1]) + 1 }, (_, i) => String(Number(range[1]) + i));
|
|
78
|
+
}
|
|
79
|
+
return nums;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Légendes, citations et liens non résolus, d'après le texte seul ; les images viennent après (`attachRegions`).
|
|
83
|
+
* 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
|
|
84
|
+
* 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
|
|
85
|
+
* (« 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
|
|
86
|
+
* (`imagePages`), à défaut la première qui a un séparateur (« Table 3 — Mapping… » contre « Table 3 Identifies… »), à défaut la
|
|
87
|
+
* première du document.
|
|
88
|
+
*/
|
|
89
|
+
export function indexText(pages, imagePages = new Set()) {
|
|
90
|
+
const last = pages.reduce((m, p) => Math.max(m, p.n), 0);
|
|
91
|
+
const toc = new Map(pages.map((p) => [p.n, tocLines(p, last)]));
|
|
92
|
+
const candidates = new Map();
|
|
93
|
+
for (const p of pages) {
|
|
94
|
+
p.lines.forEach((l, i) => {
|
|
95
|
+
const c = toc.get(p.n)[i] ? null : captionForm(l.text);
|
|
96
|
+
if (!c)
|
|
97
|
+
return;
|
|
98
|
+
const list = candidates.get(key(c.label)) ?? [];
|
|
99
|
+
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 } });
|
|
100
|
+
candidates.set(key(c.label), list);
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
const chosen = [...candidates.values()]
|
|
104
|
+
.map((candidatesOfLabel) => {
|
|
105
|
+
// 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
|
|
106
|
+
const all = candidatesOfLabel.filter((c) => !c.parenthetical || candidatesOfLabel.length === 1);
|
|
107
|
+
if (all.length === 0)
|
|
108
|
+
return null;
|
|
109
|
+
const list = all.some((c) => c.title !== '') ? all.filter((c) => c.title !== '') : all;
|
|
110
|
+
return list.find((c) => imagePages.has(c.page)) ?? list.find((c) => c.separated) ?? list[0];
|
|
111
|
+
})
|
|
112
|
+
.filter((c) => c !== null)
|
|
113
|
+
.sort((a, b) => a.page - b.page || a.line - b.line);
|
|
114
|
+
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: [] }]));
|
|
115
|
+
const captionLine = new Set(chosen.map((c) => `${c.page}:${c.line}`));
|
|
116
|
+
const unresolved = [];
|
|
117
|
+
for (const p of pages) {
|
|
118
|
+
const captions = [...figures.values()].filter((f) => f.page === p.n);
|
|
119
|
+
p.lines.forEach((l, i) => {
|
|
120
|
+
if (toc.get(p.n)[i])
|
|
121
|
+
return;
|
|
122
|
+
const line = i + 1;
|
|
123
|
+
const own = captionLine.has(`${p.n}:${line}`);
|
|
124
|
+
const seen = new Set();
|
|
125
|
+
for (const m of l.text.matchAll(REFERENCE)) {
|
|
126
|
+
// la légende ne se cite pas elle-même
|
|
127
|
+
if (own && m.index === 0)
|
|
128
|
+
continue;
|
|
129
|
+
for (const num of numbersOf(m[1], m[2])) {
|
|
130
|
+
const { label } = labelOf(m[1], num);
|
|
131
|
+
if (seen.has(key(label)))
|
|
132
|
+
continue;
|
|
133
|
+
seen.add(key(label));
|
|
134
|
+
const fig = figures.get(key(label));
|
|
135
|
+
if (fig)
|
|
136
|
+
fig.citations.push({ page: p.n, line, extrait: l.text.trim(), type: 'nommee' });
|
|
137
|
+
else
|
|
138
|
+
unresolved.push({ label, page: p.n, extrait: l.text.trim() });
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
const d = DEICTIC.exec(l.text);
|
|
142
|
+
if (!d)
|
|
143
|
+
return;
|
|
144
|
+
const nearest = captions.reduce((best, f) => (best === null || Math.abs(f.line - line) < Math.abs(best.line - line) ? f : best), null);
|
|
145
|
+
if (nearest)
|
|
146
|
+
nearest.citations.push({ page: p.n, line, extrait: l.text.trim(), type: 'designee' });
|
|
147
|
+
else
|
|
148
|
+
unresolved.push({ label: d[1].toLowerCase(), page: p.n, extrait: l.text.trim() });
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
// une figure que rien ne cite est aussi à résoudre
|
|
152
|
+
for (const f of figures.values())
|
|
153
|
+
if (f.citations.length === 0)
|
|
154
|
+
unresolved.push({ label: f.label, page: f.page, extrait: f.caption });
|
|
155
|
+
return { figures: [...figures.values()], unresolved };
|
|
156
|
+
}
|
|
157
|
+
/** Pages à rendre en plus de celles visées : celles des figures que les pages visées citent par leur nom. */
|
|
158
|
+
export function citedPages(index, wanted) {
|
|
159
|
+
const out = new Set();
|
|
160
|
+
for (const f of index.figures)
|
|
161
|
+
if (f.citations.some((c) => c.type === 'nommee' && wanted.has(c.page)))
|
|
162
|
+
out.add(f.page);
|
|
163
|
+
return out;
|
|
164
|
+
}
|
|
165
|
+
const centre = (b) => b.y + b.h / 2;
|
|
166
|
+
/** 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. */
|
|
167
|
+
export function attachRegions(index, images) {
|
|
168
|
+
images.forEach((img, rank) => {
|
|
169
|
+
const captions = index.figures.filter((f) => f.page === img.page);
|
|
170
|
+
const target = img.box
|
|
171
|
+
? 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)
|
|
172
|
+
: captions.length === 1
|
|
173
|
+
? captions[0]
|
|
174
|
+
: null;
|
|
175
|
+
target?.regions.push(rank);
|
|
176
|
+
if (captions.length === 0)
|
|
177
|
+
index.unresolved.push({ label: 'image sans légende', page: img.page, extrait: '' });
|
|
178
|
+
});
|
|
179
|
+
}
|