@sylad/cadence 1.7.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.
Files changed (94) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +26 -8
  4. package/agents/code-reviewer.md +2 -2
  5. package/agents/doc-keeper.md +19 -0
  6. package/agents/qa-reviewer.md +2 -2
  7. package/agents/spec-writer.md +1 -1
  8. package/agents/ux-reviewer.md +1 -1
  9. package/bin/cadence.js +12 -1
  10. package/dist/audit.js +14 -4
  11. package/dist/check.js +6 -0
  12. package/dist/cli.js +295 -29
  13. package/dist/config.js +147 -5
  14. package/dist/corrections.js +78 -22
  15. package/dist/deliver.js +85 -21
  16. package/dist/docsync.js +19 -3
  17. package/dist/git.js +16 -3
  18. package/dist/lead.js +37 -12
  19. package/dist/migrate.js +166 -0
  20. package/dist/orchestrate/briefs.js +28 -6
  21. package/dist/orchestrate/command.js +303 -34
  22. package/dist/orchestrate/cycle.js +720 -273
  23. package/dist/orchestrate/guard.js +2 -2
  24. package/dist/orchestrate/integrate.js +85 -0
  25. package/dist/orchestrate/launch.js +37 -5
  26. package/dist/orchestrate/mutex.js +30 -0
  27. package/dist/orchestrate/pool.js +74 -36
  28. package/dist/orchestrate/registry.js +123 -14
  29. package/dist/orchestrate/result.js +1 -1
  30. package/dist/orchestrate/snapshot.js +25 -0
  31. package/dist/orchestrate/spec.js +89 -5
  32. package/dist/orchestrate/state.js +42 -6
  33. package/dist/orchestrate/table.js +61 -2
  34. package/dist/orchestrate/timeouts.js +40 -0
  35. package/dist/orchestrate/worktree.js +368 -0
  36. package/dist/perf.js +620 -0
  37. package/dist/plan.js +92 -6
  38. package/dist/ready-free.js +109 -0
  39. package/dist/ref/add.js +116 -0
  40. package/dist/ref/check.js +338 -0
  41. package/dist/ref/command.js +78 -0
  42. package/dist/ref/corpus-render.js +83 -0
  43. package/dist/ref/corpus.js +201 -0
  44. package/dist/ref/evaluate.js +241 -0
  45. package/dist/ref/figures.js +179 -0
  46. package/dist/ref/measure.js +330 -0
  47. package/dist/ref/pdf.js +104 -0
  48. package/dist/ref/png.js +125 -0
  49. package/dist/ref/store.js +40 -0
  50. package/dist/ref/tools.js +51 -0
  51. package/dist/resources.js +51 -0
  52. package/dist/session.js +5 -2
  53. package/dist/state.js +82 -0
  54. package/dist/testgate.js +15 -0
  55. package/docs/cadence-hud.md +12 -0
  56. package/docs/corrections.md +1 -1
  57. package/docs/cycle-vague-dark.svg +1 -1
  58. package/docs/cycle-vague-light.svg +1 -1
  59. package/docs/deliver.md +58 -3
  60. package/docs/fonctionnement-wave-dark.svg +1 -1
  61. package/docs/fonctionnement-wave-light.svg +1 -1
  62. package/docs/fonctionnement.md +5 -5
  63. package/docs/lead.md +37 -12
  64. package/docs/news.md +24 -1
  65. package/docs/orchestrate.md +230 -19
  66. package/docs/perf.md +89 -0
  67. package/docs/raf.md +89 -11
  68. package/docs/ref.md +316 -0
  69. package/docs/session.md +2 -2
  70. package/docs/skills.md +9 -2
  71. package/docs/spec.md +1 -1
  72. package/docs/stabilite.md +4 -4
  73. package/docs/whats-new.md +4 -0
  74. package/package.json +3 -1
  75. package/skills/deliver/SKILL.md +6 -1
  76. package/skills/lead/SKILL.md +31 -6
  77. package/skills/session-close/SKILL.md +1 -1
  78. package/skills/session-start/SKILL.md +2 -2
  79. package/templates/inventaire.json +96 -0
  80. package/templates/orchestrate/accept-back.md +1 -0
  81. package/templates/orchestrate/accept.md +1 -0
  82. package/templates/orchestrate/fiche.md +1 -0
  83. package/templates/orchestrate/fix-docs.md +18 -0
  84. package/templates/orchestrate/fix-minors.md +2 -1
  85. package/templates/orchestrate/fix.md +5 -2
  86. package/templates/orchestrate/implement.md +6 -3
  87. package/templates/orchestrate/maquette.md +2 -1
  88. package/templates/orchestrate/precheck.md +2 -1
  89. package/templates/orchestrate/review-recheck.md +2 -0
  90. package/templates/orchestrate/review-small.md +5 -1
  91. package/templates/orchestrate/review.md +2 -0
  92. package/templates/orchestrate/spec-back.md +2 -1
  93. package/templates/orchestrate/spec.md +4 -3
  94. package/templates/orchestrate/ux.md +3 -1
@@ -0,0 +1,330 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { RafError } from '../plan.js';
6
+ import { SLUG } from './add.js';
7
+ import { decodePng, encodePng } from './png.js';
8
+ import { readDocument } from './store.js';
9
+ import { requireTools } from './tools.js';
10
+ export const QUOI = ['couleurs', 'contraste', 'boites', 'texte'];
11
+ /** Largeur d'écran de référence, en pixels, à laquelle les mesures sont ramenées. */
12
+ export const REFERENCE_WIDTH = 1440;
13
+ /** Écart d'un canal (0-255) après arrondi de la moyenne d'un carré. */
14
+ const COLOR_TOLERANCE = 1;
15
+ /** Part de la taille de police due à la forme des lettres : la hauteur de capitale n'est pas la même dans toutes les polices (0,70 à 0,73 em). */
16
+ const FONT_TOLERANCE = 5;
17
+ /** Hauteur de capitale, en part de la taille de police (Helvetica / Arial : 0,716 ; la valeur sert de repère, la tolérance couvre l'écart des autres polices). */
18
+ const CAP_HEIGHT = 0.72;
19
+ /** Distance (RVB, euclidienne) au fond à partir de laquelle un pixel n'est plus du fond ; en dessous de cette distance maximale, la zone est unie. Un seul seuil : une zone non unie a toujours un pixel de contenu. */
20
+ const FLAT_DISTANCE = 24;
21
+ /** Une boîte de moins de pixels que cela est de la poussière. */
22
+ const MIN_BOX_AREA = 16;
23
+ const MAX_BOXES = 20;
24
+ /** Une couleur qui couvre moins de cette part de la zone n'est pas dominante. */
25
+ const MIN_SHARE = 0.01;
26
+ const MAX_DOMINANT = 5;
27
+ /** Part minimale des pixels non-fond pour qu'une couleur soit une couleur de texte, et non un dégradé d'anticrénelage. */
28
+ const MIN_INK_SHARE = 0.1;
29
+ /** Confiance tesseract (0-100) à partir de laquelle la hauteur d'un mot sert à estimer la police. */
30
+ const FONT_CONFIDENCE = 60;
31
+ const hex2 = (n) => n.toString(16).padStart(2, '0');
32
+ const color = (r, g, b) => ({ hex: `#${hex2(r)}${hex2(g)}${hex2(b)}`, rgb: [r, g, b] });
33
+ const round1 = (n) => Math.round(n * 10) / 10;
34
+ const round2 = (n) => Math.round(n * 100) / 100;
35
+ /** Luminance relative WCAG 2.2 (sRGB). */
36
+ function luminance([r, g, b]) {
37
+ const lin = (v) => {
38
+ const c = v / 255;
39
+ return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
40
+ };
41
+ return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
42
+ }
43
+ /** Rapport de contraste WCAG 2.2, non arrondi. */
44
+ export function contrastRatio(a, b) {
45
+ const la = luminance(a);
46
+ const lb = luminance(b);
47
+ return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
48
+ }
49
+ /** `page`, `x,y` (un point) ou `x,y,l,h` (un carré), en pixels de la page rendue. */
50
+ export function parseZone(text, raster) {
51
+ if (text === 'page')
52
+ return { zone: { x: 0, y: 0, l: raster.width, h: raster.height }, point: false };
53
+ const parts = text.split(',');
54
+ const nums = parts.map((p) => (/^\d+$/.test(p.trim()) ? Number(p) : NaN));
55
+ if ((parts.length !== 2 && parts.length !== 4) || nums.some(Number.isNaN) || (nums.length === 4 && (nums[2] < 1 || nums[3] < 1))) {
56
+ throw new RafError(`ref : zone invalide : « ${text} » (attendu : page, x,y pour un point ou x,y,l,h pour un carré, en pixels de la page rendue)`);
57
+ }
58
+ const [x, y, l, h] = nums.length === 2 ? [nums[0], nums[1], 1, 1] : nums;
59
+ if (x + l > raster.width || y + h > raster.height)
60
+ throw new RafError(`ref : zone ${text} hors de la page (${raster.width} × ${raster.height} px)`);
61
+ return { zone: { x, y, l, h }, point: nums.length === 2 };
62
+ }
63
+ /** Pixel (r, g, b) d'une image, l'alpha composé sur du blanc. */
64
+ function pixel(r, x, y) {
65
+ const o = (y * r.width + x) * 4;
66
+ const a = r.data[o + 3];
67
+ const mix = (c) => (a === 255 ? c : Math.round((c * a + 255 * (255 - a)) / 255));
68
+ return [mix(r.data[o]), mix(r.data[o + 1]), mix(r.data[o + 2])];
69
+ }
70
+ function pixelsOf(r, z) {
71
+ const out = [];
72
+ for (let y = z.y; y < z.y + z.h; y++)
73
+ for (let x = z.x; x < z.x + z.l; x++)
74
+ out.push(pixel(r, x, y));
75
+ return out;
76
+ }
77
+ const distance = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
78
+ function meanOf(px) {
79
+ const s = [0, 0, 0];
80
+ for (const p of px)
81
+ for (let i = 0; i < 3; i++)
82
+ s[i] += p[i];
83
+ return [Math.round(s[0] / px.length), Math.round(s[1] / px.length), Math.round(s[2] / px.length)];
84
+ }
85
+ /** Couleurs regroupées par 8 niveaux par canal, de la plus étendue à la moins étendue (celles sous `minShare` de la zone sont écartées) ; chacune est la moyenne de son groupe. */
86
+ function dominant(px, minShare = MIN_SHARE) {
87
+ const groups = new Map();
88
+ for (const p of px) {
89
+ const key = ((p[0] >> 3) << 10) | ((p[1] >> 3) << 5) | (p[2] >> 3);
90
+ const g = groups.get(key);
91
+ if (g)
92
+ g.push(p);
93
+ else
94
+ groups.set(key, [p]);
95
+ }
96
+ return [...groups.entries()]
97
+ .sort((a, b) => b[1].length - a[1].length || a[0] - b[0])
98
+ .filter(([, g]) => g.length / px.length >= minShare)
99
+ .slice(0, MAX_DOMINANT)
100
+ .map(([, g]) => ({ ...color(...meanOf(g)), part: round2(g.length / px.length) }));
101
+ }
102
+ function measureColors(r, z, point) {
103
+ if (point)
104
+ return { point: color(...pixel(r, z.x, z.y)) };
105
+ const px = pixelsOf(r, z);
106
+ return { moyenne: color(...meanOf(px)), dominantes: dominant(px) };
107
+ }
108
+ /** Texte et fond d'une zone : le fond est la couleur dominante, le texte la moyenne des pixels les plus éloignés du fond. */
109
+ function inkAndBackground(r, z) {
110
+ const px = pixelsOf(r, z);
111
+ const bg = dominant(px, 0)[0].rgb;
112
+ let far = 0;
113
+ for (const p of px)
114
+ far = Math.max(far, distance(p, bg));
115
+ if (far < FLAT_DISTANCE)
116
+ return null;
117
+ const ink = px.filter((p) => distance(p, bg) >= far * 0.75);
118
+ return { bg: color(...bg), fg: color(...meanOf(ink)), px, ink: px.filter((p) => distance(p, bg) >= FLAT_DISTANCE) };
119
+ }
120
+ /** Le texte peut avoir plusieurs couleurs : chaque couleur portée par au moins `MIN_INK_SHARE` des pixels non-fond est un texte, et le verdict est celui de la moins contrastée (jamais une moyenne, qui n'existe pas dans l'image), sauf si aucune couleur n'en porte autant (dégradé, photo) : le texte est alors la moyenne des pixels les plus éloignés du fond et la sortie le signale (`texte.moyenne`). */
121
+ function measureContrast(r, z) {
122
+ const found = inkAndBackground(r, z);
123
+ if (!found)
124
+ return null;
125
+ const texts = dominant(found.ink, MIN_INK_SHARE);
126
+ // repli (dégradé, photo) : aucune couleur ne domine, le texte est la moyenne des pixels les plus éloignés du fond, et la sortie le dit
127
+ const repli = texts.length === 0;
128
+ if (repli)
129
+ texts.push({ ...found.fg, part: 1 });
130
+ const ratios = texts.map((t) => contrastRatio(t.rgb, found.bg.rgb));
131
+ const worst = ratios.indexOf(Math.min(...ratios));
132
+ const ratio = ratios[worst];
133
+ const { part: _part, ...couleur } = texts[worst];
134
+ const texte = repli ? { ...couleur, moyenne: true } : couleur;
135
+ // les seuils se lisent sur le rapport non arrondi : 4,499 n'atteint pas 4,5
136
+ return { texte, fond: found.bg, rapport: round2(ratio), aa: ratio >= 4.5, aaGrand: ratio >= 3, aaa: ratio >= 7 };
137
+ }
138
+ const scaleBox = (b, f) => {
139
+ const x0 = Math.round(b.x * f);
140
+ const y0 = Math.round(b.y * f);
141
+ return { x: x0, y: y0, l: Math.round((b.x + b.l) * f) - x0, h: Math.round((b.y + b.h) * f) - y0 };
142
+ };
143
+ function measureBoxes(r, z, f) {
144
+ const found = inkAndBackground(r, z);
145
+ if (!found)
146
+ return { fond: { hex: dominant(pixelsOf(r, z), 0)[0].hex }, contenu: null, boites: [], total: 0 };
147
+ const { bg, px } = found;
148
+ const mask = new Uint8Array(z.l * z.h);
149
+ let x0 = z.l, y0 = z.h, x1 = -1, y1 = -1;
150
+ px.forEach((p, i) => {
151
+ if (distance(p, bg.rgb) < FLAT_DISTANCE)
152
+ return;
153
+ mask[i] = 1;
154
+ const x = i % z.l;
155
+ const y = (i - x) / z.l;
156
+ x0 = Math.min(x0, x);
157
+ y0 = Math.min(y0, y);
158
+ x1 = Math.max(x1, x);
159
+ y1 = Math.max(y1, y);
160
+ });
161
+ // composantes connexes (8 voisins), chacune avec sa boîte englobante
162
+ const boxes = [];
163
+ const stack = [];
164
+ for (let start = 0; start < mask.length; start++) {
165
+ if (mask[start] !== 1)
166
+ continue;
167
+ mask[start] = 2;
168
+ stack.push(start);
169
+ let bx0 = z.l, by0 = z.h, bx1 = -1, by1 = -1, area = 0;
170
+ while (stack.length > 0) {
171
+ const i = stack.pop();
172
+ const x = i % z.l;
173
+ const y = (i - x) / z.l;
174
+ area++;
175
+ bx0 = Math.min(bx0, x);
176
+ by0 = Math.min(by0, y);
177
+ bx1 = Math.max(bx1, x);
178
+ by1 = Math.max(by1, y);
179
+ for (let dy = -1; dy <= 1; dy++) {
180
+ for (let dx = -1; dx <= 1; dx++) {
181
+ const nx = x + dx, ny = y + dy;
182
+ if (nx < 0 || ny < 0 || nx >= z.l || ny >= z.h)
183
+ continue;
184
+ const n = ny * z.l + nx;
185
+ if (mask[n] === 1) {
186
+ mask[n] = 2;
187
+ stack.push(n);
188
+ }
189
+ }
190
+ }
191
+ }
192
+ if (area >= MIN_BOX_AREA)
193
+ boxes.push({ x: z.x + bx0, y: z.y + by0, l: bx1 - bx0 + 1, h: by1 - by0 + 1, area });
194
+ }
195
+ boxes.sort((a, b) => b.area - a.area || a.y - b.y || a.x - b.x);
196
+ return {
197
+ fond: { hex: bg.hex },
198
+ contenu: scaleBox({ x: z.x + x0, y: z.y + y0, l: x1 - x0 + 1, h: y1 - y0 + 1 }, f),
199
+ boites: boxes.slice(0, MAX_BOXES).map((b) => scaleBox(b, f)),
200
+ total: boxes.length,
201
+ };
202
+ }
203
+ /** Mots de tesseract (tsv) sur le recadrage de la zone, en pixels de la page puis ramenés à l'échelle ; la hauteur de capitale en pixels de la page sert à la police. */
204
+ function readWords(tools, r, z, f) {
205
+ const work = mkdtempSync(join(tmpdir(), 'cadence-ref-'));
206
+ try {
207
+ const crop = Buffer.alloc(z.l * z.h * 4);
208
+ for (let y = 0; y < z.h; y++)
209
+ r.data.copy(crop, y * z.l * 4, ((z.y + y) * r.width + z.x) * 4, ((z.y + y) * r.width + z.x + z.l) * 4);
210
+ const file = join(work, 'zone.png');
211
+ writeFileSync(file, encodePng(z.l, z.h, crop));
212
+ let tsv;
213
+ try {
214
+ tsv = execFileSync(tools.tesseract, [file, 'stdout', '--psm', '6', '-l', 'eng', 'tsv'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 1024 * 1024 * 64 });
215
+ }
216
+ catch (e) {
217
+ throw new RafError(`ref : tesseract a échoué : ${String(e.stderr ?? e.message).trim().split('\n')[0]}`);
218
+ }
219
+ const words = [];
220
+ const caps = [];
221
+ for (const row of tsv.split('\n').slice(1)) {
222
+ const c = row.split('\t');
223
+ const texte = (c[11] ?? '').trim();
224
+ if (c[0] !== '5' || texte === '')
225
+ continue;
226
+ const confiance = Math.round(Number(c[10]));
227
+ const [left, top, width, height] = [Number(c[6]), Number(c[7]), Number(c[8]), Number(c[9])];
228
+ words.push({ texte, confiance, ...scaleBox({ x: z.x + left, y: z.y + top, l: width, h: height }, f) });
229
+ // hauteur de capitale : un mot sans jambage (g j p q y), dont une lettre monte à la hauteur de capitale (majuscule, chiffre, b d f h k l)
230
+ if (confiance >= FONT_CONFIDENCE && /^[A-Za-z0-9]+$/.test(texte) && /[A-Zbdfhkl0-9]/.test(texte) && !/[gjpqy]/.test(texte))
231
+ caps.push(height);
232
+ }
233
+ return { words, caps };
234
+ }
235
+ finally {
236
+ rmSync(work, { recursive: true, force: true });
237
+ }
238
+ }
239
+ function measureText(tools, r, z, f) {
240
+ const { words, caps } = readWords(tools, r, z, f);
241
+ if (caps.length === 0)
242
+ return { mots: words, police: null };
243
+ const sorted = [...caps].sort((a, b) => a - b);
244
+ const cap = sorted.length % 2 ? sorted[(sorted.length - 1) / 2] : (sorted[sorted.length / 2 - 1] + sorted[sorted.length / 2]) / 2;
245
+ // un pixel de hauteur de capitale mal lu pèse 1/cap de la taille
246
+ return { mots: words, police: { px: round1((cap / CAP_HEIGHT) * f), tolerancePourcent: Math.ceil(FONT_TOLERANCE + 100 / cap), mots: caps.length } };
247
+ }
248
+ const pct = (n) => `${Math.round(n * 100)} %`;
249
+ /** `cadence ref measure <slug> <page> <zone>` : mesures d'une zone d'une page rendue, ramenées à la largeur de référence. */
250
+ export function measure(o) {
251
+ const tools = requireTools(o.env);
252
+ if (!SLUG.test(o.slug))
253
+ throw new RafError(`ref : document inconnu : « ${o.slug} »`);
254
+ const docDir = join(o.refsDir(), o.slug);
255
+ const doc = readDocument(docDir);
256
+ if (!doc)
257
+ throw new RafError(`ref : document inconnu : « ${o.slug} » (cadence ref add <document.pdf> --slug ${o.slug})`);
258
+ if (!/^\d+$/.test(o.page) || Number(o.page) < 1)
259
+ throw new RafError(`ref : page invalide : « ${o.page} » (un numéro à partir de 1)`);
260
+ const n = Number(o.page);
261
+ if (n > doc.pages.length)
262
+ throw new RafError(`ref : page ${n} absente : « ${o.slug} » a ${doc.pages.length} pages`);
263
+ const file = join(docDir, 'pages', `page-${n}.png`);
264
+ if (!existsSync(file))
265
+ throw new RafError(`ref : page ${n} non rendue : cadence ref add ${doc.source} --slug ${o.slug} --sections ${n}`);
266
+ if (o.quoi !== undefined && !QUOI.includes(o.quoi))
267
+ throw new RafError(`ref : --quoi invalide : « ${o.quoi} » (couleurs, contraste, boites ou texte)`);
268
+ const largeur = o.largeur === undefined ? REFERENCE_WIDTH : Number(o.largeur);
269
+ if (!(largeur > 0) || !Number.isFinite(largeur))
270
+ throw new RafError(`ref : --largeur invalide : « ${o.largeur} » (un nombre de pixels > 0)`);
271
+ const raster = decodePng(readFileSync(file), file);
272
+ const { zone, point } = parseZone(o.zone, raster);
273
+ if (point && o.quoi !== undefined && o.quoi !== 'couleurs')
274
+ throw new RafError('ref : un point ne se mesure qu\'en couleurs (donner un carré x,y,l,h ou page)');
275
+ const wanted = o.quoi !== undefined ? [o.quoi] : point ? ['couleurs'] : [...QUOI];
276
+ const f = largeur / raster.width;
277
+ const result = {
278
+ document: o.slug,
279
+ page: n,
280
+ zone,
281
+ echelle: { largeur, facteur: round2(f) },
282
+ tolerance: { couleurs: COLOR_TOLERANCE, boitesPx: Math.max(1, Math.ceil(f)), policePourcent: FONT_TOLERANCE },
283
+ };
284
+ if (wanted.includes('couleurs'))
285
+ result.couleurs = measureColors(raster, zone, point);
286
+ if (wanted.includes('contraste'))
287
+ result.contraste = measureContrast(raster, zone);
288
+ if (wanted.includes('boites'))
289
+ result.boites = measureBoxes(raster, zone, f);
290
+ if (wanted.includes('texte'))
291
+ result.texte = measureText(tools, raster, zone, f);
292
+ if (o.json) {
293
+ o.out(JSON.stringify(result, null, 2));
294
+ return;
295
+ }
296
+ for (const line of render(result, point))
297
+ o.out(line);
298
+ }
299
+ function render(m, point) {
300
+ const z = m.zone;
301
+ const lines = [`${m.document} p.${m.page} · zone ${point ? `${z.x},${z.y}` : `${z.x},${z.y},${z.l},${z.h}`} px de la page · ramené à ${m.echelle.largeur} px (×${m.echelle.facteur})`];
302
+ const c = m.couleurs;
303
+ if (c?.point)
304
+ lines.push(`couleurs : ${c.point.hex} rgb(${c.point.rgb.join(', ')})`);
305
+ else if (c)
306
+ lines.push(`couleurs : moyenne ${c.moyenne.hex} ; dominantes ${c.dominantes.length === 0 ? 'aucune' : c.dominantes.map((d) => `${d.hex} ${pct(d.part)}`).join(', ')}`);
307
+ if ('contraste' in m) {
308
+ const k = m.contraste;
309
+ const ok = (b) => (b ? '✓' : '✗');
310
+ lines.push(k ? `contraste : ${k.rapport}:1 (texte ${k.texte.hex}${k.texte.moyenne ? ', moyenne des pixels les plus éloignés du fond : aucune couleur de texte ne domine' : ''} sur fond ${k.fond.hex}) · AA ${ok(k.aa)} · AA grand texte et composants ${ok(k.aaGrand)} · AAA ${ok(k.aaa)}` : 'contraste : zone d\'une seule couleur, rien à mesurer');
311
+ }
312
+ if (m.boites) {
313
+ const b = m.boites;
314
+ const box = (q) => `${q.x},${q.y} ${q.l}×${q.h}`;
315
+ lines.push(b.contenu ? `boîtes : fond ${b.fond.hex} · contenu ${box(b.contenu)} · ${b.total} boîte(s)${b.boites.length < b.total ? ` (${b.boites.length} plus grandes)` : ''}` : 'boîtes : zone d\'une seule couleur, aucune boîte');
316
+ for (const q of b.boites)
317
+ lines.push(` ${box(q)}`);
318
+ }
319
+ if (m.texte) {
320
+ const t = m.texte;
321
+ lines.push(t.mots.length === 0 ? 'texte : aucun mot lu' : `texte : ${t.mots.map((w) => w.texte).join(' ')}`);
322
+ for (const w of t.mots)
323
+ lines.push(` « ${w.texte} » ${w.x},${w.y} ${w.l}×${w.h} (confiance ${w.confiance})`);
324
+ if (t.mots.length > 0)
325
+ lines.push(t.police ? `police : ≈ ${t.police.px} px (±${t.police.tolerancePourcent} %, ${t.police.mots} mot(s) à hauteur de capitale)` : 'police : aucun mot exploitable pour estimer la taille');
326
+ }
327
+ const tol = m.tolerance;
328
+ lines.push(`tolérance : couleurs ±${tol.couleurs}/255 par canal, boîtes ±${tol.boitesPx} px, police ±${tol.policePourcent} % plus un pixel de hauteur de capitale`);
329
+ return lines;
330
+ }
@@ -0,0 +1,104 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { RafError } from '../plan.js';
6
+ /** Résolution du rendu des pages (points PDF → pixels). */
7
+ export const DPI = 200;
8
+ const SCALE = DPI / 72;
9
+ const px = (pt) => Math.round(pt * SCALE);
10
+ function exec(file, args, cwd) {
11
+ return execFileSync(file, args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 1024 * 1024 * 512, env: { ...process.env, LC_ALL: 'C' } });
12
+ }
13
+ /** Nombre de pages ; un PDF chiffré, abîmé ou sans page est refusé avec la raison. */
14
+ export function pageCount(tools, file) {
15
+ let out;
16
+ try {
17
+ out = exec(tools.pdfinfo, [file]);
18
+ }
19
+ catch (e) {
20
+ const err = String(e.stderr ?? '');
21
+ if (/password/i.test(err))
22
+ throw new RafError(`ref : ${file} est un PDF chiffré (mot de passe demandé), il n'est pas lu`);
23
+ throw new RafError(`ref : ${file} est un PDF abîmé ou n'est pas un PDF (${err.split('\n')[0]?.replace(/^Syntax Error[^:]*: /, '') || 'pdfinfo refuse de le lire'})`);
24
+ }
25
+ if (/^Encrypted:\s+yes/m.test(out) && /copy:no/.test(out))
26
+ throw new RafError(`ref : ${file} est un PDF chiffré dont la copie est interdite, il n'est pas lu`);
27
+ const pages = Number(/^Pages:\s+(\d+)/m.exec(out)?.[1] ?? 0);
28
+ if (pages < 1)
29
+ throw new RafError(`ref : ${file} n'a aucune page`);
30
+ return pages;
31
+ }
32
+ const attr = (tag, name) => new RegExp(`${name}="([^"]*)"`).exec(tag)?.[1] ?? '';
33
+ const unescapeXml = (s) => s.replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&apos;/g, "'").replace(/&amp;/g, '&');
34
+ /** Texte positionné de tout le document (`pdftotext -bbox-layout` : mots regroupés en lignes), en pixels de la page rendue. */
35
+ export function textLayout(tools, file) {
36
+ const xml = exec(tools.pdftotext, ['-bbox-layout', file, '-']);
37
+ const pages = [];
38
+ for (const chunk of xml.split('<page ').slice(1)) {
39
+ const head = chunk.slice(0, chunk.indexOf('>'));
40
+ const page = { n: pages.length + 1, width: px(Number(attr(head, 'width'))), height: px(Number(attr(head, 'height'))), lines: [] };
41
+ for (const m of chunk.matchAll(/<line ([^>]*)>([\s\S]*?)<\/line>/g)) {
42
+ const words = [...m[2].matchAll(/<word [^>]*>([^<]*)<\/word>/g)].map((w) => unescapeXml(w[1]));
43
+ if (words.length === 0)
44
+ continue;
45
+ const x0 = Number(attr(m[1], 'xMin'));
46
+ const y0 = Number(attr(m[1], 'yMin'));
47
+ page.lines.push({ text: words.join(' '), x: px(x0), y: px(y0), l: px(Number(attr(m[1], 'xMax')) - x0), h: px(Number(attr(m[1], 'yMax')) - y0) });
48
+ }
49
+ pages.push(page);
50
+ }
51
+ return pages;
52
+ }
53
+ /** Images embarquées (masques de transparence exclus), page par page, dans l'ordre du document. */
54
+ export function listImages(tools, file) {
55
+ const out = [];
56
+ const slots = new Map();
57
+ const ranks = new Map();
58
+ for (const row of exec(tools.pdfimages, ['-list', file]).split('\n').slice(2)) {
59
+ const c = row.trim().split(/\s+/);
60
+ if (c.length < 5)
61
+ continue;
62
+ const page = Number(c[0]);
63
+ const slot = slots.get(page) ?? 0;
64
+ slots.set(page, slot + 1);
65
+ if (c[2] === 'smask')
66
+ continue;
67
+ const rank = (ranks.get(page) ?? 0) + 1;
68
+ ranks.set(page, rank);
69
+ out.push({ page, rank, slot, pixelsW: Number(c[3]), pixelsH: Number(c[4]) });
70
+ }
71
+ return out;
72
+ }
73
+ /** Rend les pages à 200 dpi dans `dir/page-<n>.png`. */
74
+ export function renderPages(tools, file, pages, dir) {
75
+ mkdirSync(dir, { recursive: true });
76
+ for (const n of pages)
77
+ exec(tools.pdftoppm, ['-r', String(DPI), '-png', '-singlefile', '-f', String(n), '-l', String(n), file, join(dir, `page-${n}`)]);
78
+ }
79
+ /** Résolution des vignettes de toutes les pages (à parcourir pour repérer les planches d'un document sans texte). */
80
+ export const THUMB_DPI = 40;
81
+ /** Vignettes basse résolution de toutes les pages dans `dir/page-<n>.png`. */
82
+ export function renderThumbnails(tools, file, total, dir) {
83
+ mkdirSync(dir, { recursive: true });
84
+ for (let n = 1; n <= total; n++)
85
+ exec(tools.pdftoppm, ['-r', String(THUMB_DPI), '-png', '-singlefile', '-f', String(n), '-l', String(n), file, join(dir, `page-${n}`)]);
86
+ }
87
+ /** Extrait les images d'une page dans `dir/p<page>-<slot sur 3 chiffres>.png`. */
88
+ export function extractImages(tools, file, page, dir) {
89
+ mkdirSync(dir, { recursive: true });
90
+ exec(tools.pdfimages, ['-png', '-f', String(page), '-l', String(page), file, join(dir, `p${page}`)]);
91
+ }
92
+ export const imageFile = (page, slot) => `p${page}-${String(slot).padStart(3, '0')}.png`;
93
+ /** Régions des images d'une page (ordre du document), en pixels de la page rendue : `pdftohtml -xml` au zoom 200/72. */
94
+ export function imageRegions(tools, file, page) {
95
+ const work = mkdtempSync(join(tmpdir(), 'cadence-ref-'));
96
+ try {
97
+ exec(tools.pdftohtml, ['-xml', '-q', '-nodrm', '-zoom', String(SCALE), '-f', String(page), '-l', String(page), file, join(work, 'p')]);
98
+ const xml = readFileSync(join(work, 'p.xml'), 'utf8');
99
+ return [...xml.matchAll(/<image ([^>]*)\/>/g)].map((m) => ({ page, x: Number(attr(m[1], 'left')), y: Number(attr(m[1], 'top')), l: Number(attr(m[1], 'width')), h: Number(attr(m[1], 'height')) }));
100
+ }
101
+ finally {
102
+ rmSync(work, { recursive: true, force: true });
103
+ }
104
+ }
@@ -0,0 +1,125 @@
1
+ import { inflateSync, deflateSync } from 'node:zlib';
2
+ import { RafError } from '../plan.js';
3
+ const SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
4
+ /** Type de couleur PNG → nombre de canaux (0 gris, 2 RVB, 4 gris + alpha, 6 RVBA). La palette (3) n'est pas lue. */
5
+ const CHANNELS = { 0: 1, 2: 3, 4: 2, 6: 4 };
6
+ /**
7
+ * Décode un PNG 8 bits non entrelacé (ce que `pdftoppm` écrit) en RVBA. Un PNG d'un autre genre est refusé avec la raison :
8
+ * `measure` mesure les pixels, il ne les devine pas.
9
+ */
10
+ export function decodePng(buf, what) {
11
+ if (buf.length < 33 || !buf.subarray(0, 8).equals(SIGNATURE))
12
+ throw new RafError(`ref : ${what} n'est pas un PNG`);
13
+ let width = 0;
14
+ let height = 0;
15
+ let type = -1;
16
+ const idat = [];
17
+ for (let at = 8; at + 8 <= buf.length;) {
18
+ const len = buf.readUInt32BE(at);
19
+ const name = buf.toString('latin1', at + 4, at + 8);
20
+ const body = buf.subarray(at + 8, at + 8 + len);
21
+ if (name === 'IHDR') {
22
+ width = body.readUInt32BE(0);
23
+ height = body.readUInt32BE(4);
24
+ type = body[9];
25
+ if (body[8] !== 8)
26
+ throw new RafError(`ref : ${what} : PNG à ${body[8]} bits par canal, seuls les PNG 8 bits sont lus`);
27
+ if (body[12] !== 0)
28
+ throw new RafError(`ref : ${what} : PNG entrelacé, non lu`);
29
+ }
30
+ else if (name === 'IDAT')
31
+ idat.push(body);
32
+ else if (name === 'IEND')
33
+ break;
34
+ at += 12 + len;
35
+ }
36
+ const channels = CHANNELS[type];
37
+ if (channels === undefined || width < 1 || height < 1)
38
+ throw new RafError(`ref : ${what} : PNG de type de couleur ${type} non lu (gris, RVB, avec ou sans alpha)`);
39
+ let raw;
40
+ try {
41
+ raw = inflateSync(Buffer.concat(idat));
42
+ }
43
+ catch {
44
+ throw new RafError(`ref : ${what} : PNG abîmé (données illisibles)`);
45
+ }
46
+ const stride = width * channels;
47
+ if (raw.length < (stride + 1) * height)
48
+ throw new RafError(`ref : ${what} : PNG tronqué`);
49
+ const data = Buffer.alloc(width * height * 4);
50
+ const line = Buffer.alloc(stride);
51
+ let prev = Buffer.alloc(stride);
52
+ for (let y = 0; y < height; y++) {
53
+ const filter = raw[y * (stride + 1)];
54
+ for (let i = 0; i < stride; i++) {
55
+ const x = raw[y * (stride + 1) + 1 + i];
56
+ const a = i >= channels ? line[i - channels] : 0;
57
+ const b = prev[i];
58
+ const c = i >= channels ? prev[i - channels] : 0;
59
+ let v;
60
+ if (filter === 0)
61
+ v = x;
62
+ else if (filter === 1)
63
+ v = x + a;
64
+ else if (filter === 2)
65
+ v = x + b;
66
+ else if (filter === 3)
67
+ v = x + ((a + b) >> 1);
68
+ else if (filter === 4) {
69
+ const p = a + b - c;
70
+ const pa = Math.abs(p - a);
71
+ const pb = Math.abs(p - b);
72
+ const pc = Math.abs(p - c);
73
+ v = x + (pa <= pb && pa <= pc ? a : pb <= pc ? b : c);
74
+ }
75
+ else
76
+ throw new RafError(`ref : ${what} : PNG abîmé (filtre ${filter})`);
77
+ line[i] = v & 0xff;
78
+ }
79
+ for (let x = 0; x < width; x++) {
80
+ const o = (y * width + x) * 4;
81
+ const s = x * channels;
82
+ if (channels <= 2)
83
+ data.set([line[s], line[s], line[s], channels === 2 ? line[s + 1] : 255], o);
84
+ else
85
+ data.set([line[s], line[s + 1], line[s + 2], channels === 4 ? line[s + 3] : 255], o);
86
+ }
87
+ prev = Buffer.from(line);
88
+ }
89
+ return { width, height, data };
90
+ }
91
+ const CRC = (() => {
92
+ const table = new Uint32Array(256);
93
+ for (let n = 0; n < 256; n++) {
94
+ let c = n;
95
+ for (let k = 0; k < 8; k++)
96
+ c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
97
+ table[n] = c >>> 0;
98
+ }
99
+ return table;
100
+ })();
101
+ function crc32(buf) {
102
+ let c = 0xffffffff;
103
+ for (const b of buf)
104
+ c = CRC[(c ^ b) & 0xff] ^ (c >>> 8);
105
+ return (c ^ 0xffffffff) >>> 0;
106
+ }
107
+ function chunk(name, body) {
108
+ const head = Buffer.alloc(8);
109
+ head.writeUInt32BE(body.length, 0);
110
+ head.write(name, 4, 'latin1');
111
+ const crc = Buffer.alloc(4);
112
+ crc.writeUInt32BE(crc32(Buffer.concat([head.subarray(4), body])), 0);
113
+ return Buffer.concat([head, body, crc]);
114
+ }
115
+ /** Encode du RVBA en PNG RVBA 8 bits (sans filtre) : le recadrage que tesseract lit, et les images générées des tests. */
116
+ export function encodePng(width, height, rgba) {
117
+ const ihdr = Buffer.alloc(13);
118
+ ihdr.writeUInt32BE(width, 0);
119
+ ihdr.writeUInt32BE(height, 4);
120
+ ihdr.set([8, 6, 0, 0, 0], 8);
121
+ const rows = Buffer.alloc((width * 4 + 1) * height);
122
+ for (let y = 0; y < height; y++)
123
+ rgba.copy(rows, y * (width * 4 + 1) + 1, y * width * 4, (y + 1) * width * 4);
124
+ return Buffer.concat([SIGNATURE, chunk('IHDR', ihdr), chunk('IDAT', deflateSync(rows)), chunk('IEND', Buffer.alloc(0))]);
125
+ }
@@ -0,0 +1,40 @@
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { dirname, join, resolve } from 'node:path';
4
+ import { readReferencesConfig } from '../config.js';
5
+ import { gitRoot } from '../git.js';
6
+ import { RafError } from '../plan.js';
7
+ /**
8
+ * Dossier des documents : `references.dir` de cadence.yaml (relatif à son dossier), sinon `$XDG_DATA_HOME/cadence/references`.
9
+ * Un `references.dir` dans un dépôt Git est refusé : les documents de référence peuvent être privés, ils ne se commitent pas.
10
+ */
11
+ export function referencesDir(root, configPath, env) {
12
+ const dir = readReferencesConfig(configPath).dir;
13
+ if (dir === undefined)
14
+ return join(env.XDG_DATA_HOME || join(env.HOME || homedir(), '.local', 'share'), 'cadence', 'references');
15
+ const abs = resolve(dirname(configPath), dir);
16
+ let probe = abs;
17
+ while (!existsSync(probe) && dirname(probe) !== probe)
18
+ probe = dirname(probe);
19
+ if (gitRoot(probe))
20
+ throw new RafError(`ref : references.dir (${abs}) est dans un dépôt Git : les documents de référence restent hors du dépôt (choisir un dossier ailleurs, ou retirer la clé pour $XDG_DATA_HOME/cadence/references)`);
21
+ return abs;
22
+ }
23
+ export const documentFile = (docDir) => join(docDir, 'document.json');
24
+ export function readDocument(docDir) {
25
+ const file = documentFile(docDir);
26
+ if (!existsSync(file))
27
+ return null;
28
+ try {
29
+ return JSON.parse(readFileSync(file, 'utf8'));
30
+ }
31
+ catch (e) {
32
+ throw new RafError(`ref : ${file} illisible : ${e.message.split('\n')[0]}`);
33
+ }
34
+ }
35
+ export function writeDocument(docDir, doc) {
36
+ mkdirSync(docDir, { recursive: true });
37
+ const tmp = `${documentFile(docDir)}.tmp`;
38
+ writeFileSync(tmp, `${JSON.stringify(doc, null, 2)}\n`);
39
+ renameSync(tmp, documentFile(docDir));
40
+ }
@@ -0,0 +1,51 @@
1
+ import { accessSync, constants, statSync } from 'node:fs';
2
+ import { delimiter, join } from 'node:path';
3
+ import { RafError } from '../plan.js';
4
+ const POPPLER = 'sudo apt install poppler-utils';
5
+ /** Les outils du système dont `cadence ref` dépend, dans l'ordre où l'erreur les nomme. */
6
+ export const TOOLS = [
7
+ { name: 'pdftoppm', commands: ['pdftoppm'], install: POPPLER },
8
+ { name: 'pdftotext', commands: ['pdftotext'], install: POPPLER },
9
+ { name: 'pdfimages', commands: ['pdfimages'], install: POPPLER },
10
+ { name: 'pdfinfo', commands: ['pdfinfo'], install: POPPLER },
11
+ { name: 'pdftohtml', commands: ['pdftohtml'], install: POPPLER },
12
+ { name: 'magick', commands: ['magick', 'convert'], install: 'sudo apt install imagemagick' },
13
+ { name: 'tesseract', commands: ['tesseract'], install: 'sudo apt install tesseract-ocr' },
14
+ ];
15
+ function executable(file) {
16
+ try {
17
+ if (!statSync(file).isFile())
18
+ return false;
19
+ accessSync(file, constants.X_OK);
20
+ return true;
21
+ }
22
+ catch {
23
+ return false;
24
+ }
25
+ }
26
+ function which(command, path) {
27
+ for (const dir of path.split(delimiter)) {
28
+ if (dir && executable(join(dir, command)))
29
+ return join(dir, command);
30
+ }
31
+ return null;
32
+ }
33
+ /**
34
+ * Chemin absolu de chaque outil, cherché dans le PATH de l'environnement de la commande. Un outil absent est nommé avec sa
35
+ * commande d'installation (une ligne par outil), la commande sort en 2 : rien n'est installé d'office.
36
+ */
37
+ export function requireTools(env) {
38
+ const path = env.PATH ?? '';
39
+ const found = {};
40
+ const missing = [];
41
+ for (const t of TOOLS) {
42
+ const hit = t.commands.map((c) => which(c, path)).find((p) => p !== null);
43
+ if (hit)
44
+ found[t.name] = hit;
45
+ else
46
+ missing.push(` ${t.commands.join(' ou ')} : ${t.install}`);
47
+ }
48
+ if (missing.length > 0)
49
+ throw new RafError(`ref : outil absent du PATH, à installer (rien n'est installé d'office) :\n${missing.join('\n')}`);
50
+ return found;
51
+ }