// Contrôle avant génération : passe chaque champ affiché sur TOUS les // bénévoles (estimation de mise en page, voir `layout.ts`) et relève ce qui // risque de mal s'imprimer. import type { BadgeFace, BadgeFieldType } from '@/api/schema'; import { contrastRatio, MIN_CONTRAST } from './layout'; import { renderField, SAFE_MARGIN_MM, type BadgeData, type FieldRef, type FieldState } from './model'; export interface FieldStats { /** Bénévoles dont le texte a été réduit automatiquement (sans être coupé). */ reduced: number; /** Bénévoles dont le texte reste coupé. */ truncated: number; /** Plus petite taille atteinte par la réduction automatique. */ minPt: number; /** Index (dans `BadgeData.volunteers`) d'un exemple de chaque cas. */ reducedExample: number | null; truncatedExample: number | null; } export type IssueKind = 'truncated' | 'reduced' | 'margin' | 'contrast' | 'noPhoto'; export interface BadgeIssue { kind: IssueKind; critical: boolean; ref: FieldRef; count: number; minPt: number; /** Bénévole à afficher pour « Voir un exemple » (null : pas d'exemple). */ exampleIndex: number | null; /** « Activer la réduction auto » proposé (texte coupé, réduction coupée). */ canEnableAutoFit: boolean; } export function statsKey(face: BadgeFace, type: BadgeFieldType): string { return `${face}:${type}`; } export function computeIssues( fields: FieldState[], data: BadgeData, badgeW: number, badgeH: number, ): { issues: BadgeIssue[]; stats: Map } { const stats = new Map(); const issues: BadgeIssue[] = []; for (const field of fields) { if (!field.enabled) continue; const ref = { face: field.face, type: field.field_type }; const base = { ref, count: 0, minPt: field.font_size, exampleIndex: null, canEnableAutoFit: false }; if (field.field_type !== 'PHOTO') { const s: FieldStats = { reduced: 0, truncated: 0, minPt: field.font_size, reducedExample: null, truncatedExample: null, }; data.volunteers.forEach((volunteer, index) => { const rendered = renderField(field, volunteer, data); if (rendered.kind === 'photo') return; const { truncated, reduced, fontSize } = rendered.layout; if (truncated) { s.truncated += 1; if (s.truncatedExample === null) s.truncatedExample = index; } else if (reduced) { s.reduced += 1; if (fontSize < s.minPt) { s.minPt = fontSize; s.reducedExample = index; } } }); stats.set(statsKey(field.face, field.field_type), s); if (s.truncated) { issues.push({ ...base, kind: 'truncated', critical: true, count: s.truncated, exampleIndex: s.truncatedExample, canEnableAutoFit: !field.auto_fit, }); } else if (s.reduced) { issues.push({ ...base, kind: 'reduced', critical: false, count: s.reduced, minPt: s.minPt, exampleIndex: s.reducedExample, }); } if (field.bg_color && contrastRatio(field.color, field.bg_color) < MIN_CONTRAST) { issues.push({ ...base, kind: 'contrast', critical: true }); } } else { const missing = data.volunteers.findIndex((v) => !v.has_photo); if (missing >= 0) { issues.push({ ...base, kind: 'noPhoto', critical: false, count: data.volunteers.filter((v) => !v.has_photo).length, exampleIndex: missing, }); } } // Un bandeau coloré (fond de zone) peut volontairement aller jusqu'au // bord : seul un champ sans fond qui touche la marge est signalé. const bleed = field.bg_color !== null && field.field_type !== 'PHOTO'; const m = SAFE_MARGIN_MM - 1e-6; const touchesEdge = field.x_mm < m || field.y_mm < m || field.x_mm + field.width_mm > badgeW - m || field.y_mm + field.height_mm > badgeH - m; if (touchesEdge && !bleed) issues.push({ ...base, kind: 'margin', critical: false }); } issues.sort((a, b) => Number(b.critical) - Number(a.critical)); return { issues, stats }; }