namespace MaBibli.Shared.Textes; /// /// Compare des libellés qui contiennent des nombres, en lisant les nombres comme des nombres. /// /// /// Le tri ordinaire range les tomes 1, 10, 11, 2, 9 — parce que le caractère 1 /// précède le 2. C'est correct alphabétiquement, et absurde pour une saga. Ce /// comparateur découpe le libellé en blocs de chiffres et blocs de texte, et compare chaque /// bloc de chiffres comme un entier. /// /// ⚠️ Aucune collation culturelle, comme partout ailleurs dans le projet /// (GroupementCatalogue, FiltreLivresLocal) : la comparaison des blocs de texte /// est ordinale, sur leur forme normalisée. C'est ce qui garantit que deux appareils /// classent une série dans le même ordre, quelle que soit la langue du système. /// /// /// Ordre retenu, tranché avec l'utilisateur le 2026-08-22 : /// /// /// le libellé vide — ce dont on ignore le numéro ; /// ce qui ne commence pas par un chiffre (« Hors-série », « Intégrale 1-3 »), /// entre eux de a à z ; /// ce qui commence par un chiffre, de 0 à 9 en tri naturel. /// /// /// ⚠️ Cet ordre diverge de celui des numéros de revue, où ce qui n'a pas de date /// ferme la liste. C'est délibéré : devant un kiosque on cherche le dernier paru, /// devant une étagère on cherche ce qu'il reste à compléter. Ne pas uniformiser les deux au /// nom de la cohérence — ce serait remplacer deux règles justes par une qui se trompe une /// fois sur deux. /// /// public static class TriNaturel { /// Comparateur prêt à l'emploi pour OrderBy. public static IComparer Comparateur { get; } = new ComparateurNaturel(); /// /// Compare deux libellés. null et la chaîne vide sont équivalents et passent en tête. /// public static int Comparer(string? gauche, string? droite) { var a = NormalisationTexte.Normaliser(gauche); var b = NormalisationTexte.Normaliser(droite); // 1. Le vide d'abord — on ignore le numéro, et c'est ce qui demande une décision. if (a.Length == 0 || b.Length == 0) { return a.Length == b.Length ? 0 : a.Length == 0 ? -1 : 1; } // 2. Puis ce qui ne commence pas par un chiffre. Un seul test sur le PREMIER caractère : // « Intégrale 1-3 » est du texte, « 3.5 » est un nombre, et la frontière est là. var aChiffre = char.IsAsciiDigit(a[0]); var bChiffre = char.IsAsciiDigit(b[0]); if (aChiffre != bChiffre) { return aChiffre ? 1 : -1; } return ComparerBlocs(a, b); } /// /// Parcourt les deux libellés en parallèle, bloc de chiffres contre bloc de chiffres, /// bloc de texte contre bloc de texte. /// private static int ComparerBlocs(string a, string b) { int i = 0, j = 0; while (i < a.Length && j < b.Length) { if (char.IsAsciiDigit(a[i]) && char.IsAsciiDigit(b[j])) { var debutA = i; var debutB = j; while (i < a.Length && char.IsAsciiDigit(a[i])) i++; while (j < b.Length && char.IsAsciiDigit(b[j])) j++; // ⚠️ Comparaison par LONGUEUR puis par caractères, jamais par conversion en int : // un numéro n'est pas borné par int.MaxValue, et « 000000000000000012 » ne doit // pas faire échouer le tri. Les zéros de tête sont ignorés d'abord. var chiffresA = a.AsSpan(debutA, i - debutA).TrimStart('0'); var chiffresB = b.AsSpan(debutB, j - debutB).TrimStart('0'); if (chiffresA.Length != chiffresB.Length) { return chiffresA.Length < chiffresB.Length ? -1 : 1; } var ecart = chiffresA.SequenceCompareTo(chiffresB); if (ecart != 0) { return Math.Sign(ecart); } } else { var ecart = a[i].CompareTo(b[j]); if (ecart != 0) { return Math.Sign(ecart); } i++; j++; } } // Le plus court d'abord quand l'un est le préfixe de l'autre : « 3 » avant « 3 bis ». return (a.Length - i).CompareTo(b.Length - j); } private sealed class ComparateurNaturel : IComparer { public int Compare(string? x, string? y) => Comparer(x, y); } }