diff --git a/MaBibli.Shared/Textes/NormalisationTexte.cs b/MaBibli.Shared/Textes/NormalisationTexte.cs
new file mode 100644
index 0000000..ead925d
--- /dev/null
+++ b/MaBibli.Shared/Textes/NormalisationTexte.cs
@@ -0,0 +1,77 @@
+using System.Globalization;
+using System.Text;
+
+namespace MaBibli.Shared.Textes;
+
+///
+/// Mise à plat d'un texte pour la recherche et le regroupement : minuscules, sans accents,
+/// sans ponctuation, espaces réduits.
+///
+///
+/// Pourquoi une forme stockée plutôt qu'une comparaison à la volée ? SQLite ne sait pas
+/// comparer sans tenir compte des accents : sa collation NOCASE ne couvre que l'ASCII, et
+/// il n'existe pas de unaccent() intégré. Faire le travail en C# à l'écriture, dans une
+/// colonne dédiée, est la seule façon d'obtenir « Emile » → « Émile » sans charger tout
+/// le catalogue en mémoire à chaque recherche.
+///
+/// Conséquence à ne pas oublier : toute écriture doit recalculer la forme normalisée, et le
+/// terme cherché doit passer par la même fonction que la valeur stockée.
+///
+///
+public static class NormalisationTexte
+{
+ ///
+ /// Forme comparable d'un texte quelconque : « Émile ZOLA » → « emile zola ».
+ ///
+ ///
+ /// La ponctuation devient une espace plutôt que de disparaître : « P.F. Hamilton » doit
+ /// donner trois mots (p f hamilton) et non un seul (pf hamilton), sans quoi
+ /// le rapprochement par initiales serait impossible.
+ ///
+ public static string Normaliser(string? brut)
+ {
+ if (string.IsNullOrWhiteSpace(brut))
+ {
+ return string.Empty;
+ }
+
+ // NFD sépare la lettre de son accent ; on ne garde ensuite que la lettre.
+ var decompose = brut.Normalize(NormalizationForm.FormD);
+ var sortie = new StringBuilder(decompose.Length);
+ var espaceEnAttente = false;
+
+ foreach (var c in decompose)
+ {
+ if (CharUnicodeInfo.GetUnicodeCategory(c) == UnicodeCategory.NonSpacingMark)
+ {
+ continue; // Accent détaché par la décomposition.
+ }
+
+ if (char.IsLetterOrDigit(c))
+ {
+ if (espaceEnAttente && sortie.Length > 0)
+ {
+ sortie.Append(' ');
+ }
+
+ espaceEnAttente = false;
+ sortie.Append(char.ToLowerInvariant(c));
+ continue;
+ }
+
+ // Tout le reste (espaces, points, virgules, tirets…) est un séparateur de mots.
+ espaceEnAttente = true;
+ }
+
+ return sortie.ToString().Normalize(NormalizationForm.FormC);
+ }
+
+ /// Mots de la forme normalisée, sans mot vide.
+ public static string[] Mots(string? brut)
+ {
+ var normalise = Normaliser(brut);
+ return normalise.Length == 0
+ ? []
+ : normalise.Split(' ', StringSplitOptions.RemoveEmptyEntries);
+ }
+}
diff --git a/MaBibli.Shared/Textes/RapprochementAuteurs.cs b/MaBibli.Shared/Textes/RapprochementAuteurs.cs
new file mode 100644
index 0000000..d442925
--- /dev/null
+++ b/MaBibli.Shared/Textes/RapprochementAuteurs.cs
@@ -0,0 +1,160 @@
+namespace MaBibli.Shared.Textes;
+
+/// Verdict du rapprochement de deux noms d'auteur.
+public enum NiveauRapprochement
+{
+ /// Rien de commun : ce sont deux auteurs distincts.
+ Aucun = 0,
+
+ ///
+ /// Variante sûre : même nom à la casse, aux accents, à l'ordre ou à l'abréviation près.
+ /// Peut être fusionné automatiquement.
+ ///
+ Certain = 1,
+
+ ///
+ /// Rapprochement plausible mais ambigu : un nom est contenu dans l'autre
+ /// (« Hamilton » vers « Peter F. Hamilton »). À proposer, jamais à appliquer seul.
+ ///
+ Suggere = 2,
+}
+
+///
+/// Règles de rapprochement des noms d'auteurs.
+///
+///
+/// Décision actée dans CLAUDE.md : le regroupement automatique se limite aux variantes
+/// sûres — casse, accents, ordre Nom, Prénom ↔ Prénom Nom, initiales abrégées
+/// quand le reste concorde. Tout le reste est proposé à l'utilisateur.
+///
+/// La raison est asymétrique : ne pas fusionner laisse deux fiches à réunir d'un clic, alors
+/// qu'une fusion erronée mélange les livres de deux personnes et se défait mal. Dans le doute,
+/// on ne fusionne pas.
+///
+///
+public static class RapprochementAuteurs
+{
+ ///
+ /// Clé de regroupement : mots normalisés triés, ce qui rend l'ordre indifférent.
+ ///
+ ///
+ /// « Zola, Émile » et « Émile Zola » donnent tous deux emile zola. C'est cette clé qui
+ /// porte l'unicité en base : deux auteurs ne peuvent pas coexister avec la même.
+ ///
+ /// Elle ne couvre pas les initiales (p f hamilton ≠ f hamilton peter) :
+ /// ce cas relève de , qui aligne les mots au lieu de les hacher.
+ ///
+ ///
+ public static string Cle(string? nom)
+ {
+ var mots = NormalisationTexte.Mots(nom);
+ Array.Sort(mots, StringComparer.Ordinal);
+ return string.Join(' ', mots);
+ }
+
+ ///
+ /// Compare deux noms d'auteur et dit s'ils désignent sûrement, peut-être, ou sûrement pas
+ /// la même personne.
+ ///
+ public static NiveauRapprochement Comparer(string? a, string? b)
+ {
+ var motsA = NormalisationTexte.Mots(a);
+ var motsB = NormalisationTexte.Mots(b);
+
+ if (motsA.Length == 0 || motsB.Length == 0)
+ {
+ return NiveauRapprochement.Aucun;
+ }
+
+ if (motsA.Length == motsB.Length)
+ {
+ // Même nombre de mots : soit tout s'aligne (variante sûre), soit ce sont deux personnes.
+ // On exige au moins un mot entier commun : « P. H. » et « Peter Hamilton » s'alignent
+ // par les seules initiales, ce qui ne concorde sur rien de vérifiable.
+ return Aligne(motsA, motsB, out var pleinPartage) && pleinPartage
+ ? NiveauRapprochement.Certain
+ : NiveauRapprochement.Aucun;
+ }
+
+ // Nombres de mots différents : au mieux l'un est un fragment de l'autre
+ // (« Hamilton » ⊂ « Peter F. Hamilton »). Jamais sûr — deux personnes partagent
+ // couramment un patronyme.
+ var (court, longue) = motsA.Length < motsB.Length ? (motsA, motsB) : (motsB, motsA);
+
+ return Aligne(court, longue, out var motPleinPartage) && motPleinPartage
+ ? NiveauRapprochement.Suggere
+ : NiveauRapprochement.Aucun;
+ }
+
+ /// Raccourci : les deux noms sont-ils une variante sûre l'un de l'autre ?
+ public static bool SontLeMemeAuteur(string? a, string? b) =>
+ Comparer(a, b) == NiveauRapprochement.Certain;
+
+ ///
+ /// Cherche à placer chaque mot de sur un mot distinct de
+ /// , par égalité ou par initiale.
+ ///
+ ///
+ /// Vrai si au moins un couple aligné est une égalité de mots entiers. Sans cette
+ /// garantie, « P. » se rapprocherait de n'importe quel prénom commençant par P — une
+ /// suggestion sans valeur qui n'apprendrait rien à l'utilisateur.
+ ///
+ ///
+ /// Recherche exhaustive avec retour arrière. Les noms de personnes tiennent en quelques mots,
+ /// le coût est négligeable ; une correspondance gloutonne, elle, échouerait sur
+ /// « P. Peter » vs « Peter Paul » en consommant le mauvais mot en premier.
+ ///
+ private static bool Aligne(string[] aPlacer, string[] cible, out bool motPleinPartage)
+ {
+ var pris = new bool[cible.Length];
+ motPleinPartage = false;
+ return Placer(aPlacer, cible, pris, 0, ref motPleinPartage);
+ }
+
+ private static bool Placer(
+ string[] aPlacer, string[] cible, bool[] pris, int index, ref bool motPleinPartage)
+ {
+ if (index == aPlacer.Length)
+ {
+ return true;
+ }
+
+ var mot = aPlacer[index];
+
+ for (var i = 0; i < cible.Length; i++)
+ {
+ if (pris[i])
+ {
+ continue;
+ }
+
+ var autre = cible[i];
+ var egalite = mot == autre;
+ var initiale = !egalite && EstInitialeDe(mot, autre);
+
+ if (!egalite && !initiale)
+ {
+ continue;
+ }
+
+ pris[i] = true;
+ var pleinAvant = motPleinPartage;
+ motPleinPartage = pleinAvant || (egalite && mot.Length > 1);
+
+ if (Placer(aPlacer, cible, pris, index + 1, ref motPleinPartage))
+ {
+ return true;
+ }
+
+ pris[i] = false;
+ motPleinPartage = pleinAvant;
+ }
+
+ return false;
+ }
+
+ /// « p » est l'initiale de « peter ». Une lettre seule d'un côté, le mot de l'autre.
+ private static bool EstInitialeDe(string a, string b) =>
+ (a.Length == 1 && b.Length > 1 && b[0] == a[0])
+ || (b.Length == 1 && a.Length > 1 && a[0] == b[0]);
+}
diff --git a/MaBibli.Tests/RapprochementAuteursTests.cs b/MaBibli.Tests/RapprochementAuteursTests.cs
new file mode 100644
index 0000000..da3309e
--- /dev/null
+++ b/MaBibli.Tests/RapprochementAuteursTests.cs
@@ -0,0 +1,121 @@
+using MaBibli.Shared.Textes;
+
+namespace MaBibli.Tests;
+
+///
+/// Règles de regroupement des noms d'auteurs.
+///
+///
+/// La frontière testée ici est celle que CLAUDE.md pose : ce qui est sûr est fusionné, ce qui
+/// est ambigu est proposé. Les tests sont écrits par catégorie plutôt que par cas, pour que
+/// l'ajout d'une règle oblige à dire de quel côté de la frontière elle tombe.
+///
+public class RapprochementAuteursTests
+{
+ [Theory]
+ // Casse et accents.
+ [InlineData("Émile Zola", "emile zola")]
+ [InlineData("Émile Zola", "EMILE ZOLA")]
+ [InlineData("Émile Zola", "Emile Zola")]
+ // Ordre inversé, avec ou sans virgule.
+ [InlineData("Zola, Émile", "Émile Zola")]
+ [InlineData("Zola, Emile", "emile zola")]
+ // Initiales abrégées, le reste concordant.
+ [InlineData("Peter F. Hamilton", "P.F. Hamilton")]
+ [InlineData("Peter F. Hamilton", "P. F. Hamilton")]
+ [InlineData("P.F. Hamilton", "P. F. Hamilton")]
+ [InlineData("Thomas H. Cormen", "T. H. Cormen")]
+ // Combinaison des trois.
+ [InlineData("Hamilton, Peter F.", "p f hamilton")]
+ // Auteur mononyme.
+ [InlineData("Homère", "homere")]
+ public void Les_variantes_sures_sont_reconnues(string a, string b)
+ {
+ Assert.Equal(NiveauRapprochement.Certain, RapprochementAuteurs.Comparer(a, b));
+ Assert.Equal(NiveauRapprochement.Certain, RapprochementAuteurs.Comparer(b, a));
+ }
+
+ [Theory]
+ // Le cas d'école de CLAUDE.md : un patronyme seul face à un nom complet.
+ [InlineData("Hamilton", "Peter F. Hamilton")]
+ [InlineData("Zola", "Émile Zola")]
+ [InlineData("Cormen", "Thomas H. Cormen")]
+ // Un prénom en plus, ou en moins.
+ [InlineData("Émile Zola", "Émile Édouard Zola")]
+ public void Les_rapprochements_ambigus_sont_seulement_proposes(string a, string b)
+ {
+ Assert.Equal(NiveauRapprochement.Suggere, RapprochementAuteurs.Comparer(a, b));
+ Assert.Equal(NiveauRapprochement.Suggere, RapprochementAuteurs.Comparer(b, a));
+
+ // Et surtout : jamais fusionnés d'office.
+ Assert.False(RapprochementAuteurs.SontLeMemeAuteur(a, b));
+ }
+
+ [Theory]
+ // Deux personnes qui partagent un patronyme : rien ne doit les rapprocher automatiquement.
+ [InlineData("Peter Hamilton", "Paul Hamilton")]
+ [InlineData("Peter F. Hamilton", "Peter G. Hamilton")]
+ // Homonymes partiels sans mot entier commun.
+ [InlineData("Émile Zola", "Émile Ajar")]
+ [InlineData("Voltaire", "Molière")]
+ // Une initiale seule ne concorde sur rien de vérifiable.
+ [InlineData("P. H.", "Peter Hamilton")]
+ [InlineData("P. Hamilton", "Peter Paul")]
+ public void Les_auteurs_distincts_ne_sont_pas_rapproches(string a, string b)
+ {
+ Assert.Equal(NiveauRapprochement.Aucun, RapprochementAuteurs.Comparer(a, b));
+ Assert.Equal(NiveauRapprochement.Aucun, RapprochementAuteurs.Comparer(b, a));
+ }
+
+ [Fact]
+ public void Un_nom_vide_ne_se_rapproche_de_rien()
+ {
+ Assert.Equal(NiveauRapprochement.Aucun, RapprochementAuteurs.Comparer(null, "Émile Zola"));
+ Assert.Equal(NiveauRapprochement.Aucun, RapprochementAuteurs.Comparer(" ", "Émile Zola"));
+ Assert.Equal(NiveauRapprochement.Aucun, RapprochementAuteurs.Comparer(".,;", "Émile Zola"));
+ }
+
+ [Fact]
+ public void La_cle_est_identique_pour_les_variantes_dordre_et_daccents()
+ {
+ var attendue = RapprochementAuteurs.Cle("Émile Zola");
+
+ Assert.Equal(attendue, RapprochementAuteurs.Cle("Zola, Émile"));
+ Assert.Equal(attendue, RapprochementAuteurs.Cle("emile zola"));
+ Assert.Equal(attendue, RapprochementAuteurs.Cle(" ZOLA, Emile "));
+ Assert.Equal("emile zola", attendue);
+ }
+
+ ///
+ /// La clé ne sait pas rapprocher les initiales : c'est justement pour ça que
+ /// existe en plus de l'index unique.
+ ///
+ [Fact]
+ public void La_cle_ne_couvre_pas_les_initiales()
+ {
+ Assert.NotEqual(
+ RapprochementAuteurs.Cle("Peter F. Hamilton"),
+ RapprochementAuteurs.Cle("P.F. Hamilton"));
+
+ Assert.True(RapprochementAuteurs.SontLeMemeAuteur("Peter F. Hamilton", "P.F. Hamilton"));
+ }
+
+ [Theory]
+ [InlineData("Émile ZOLA", "emile zola")]
+ [InlineData("P.F. Hamilton", "p f hamilton")]
+ [InlineData(" Zola, Émile ", "zola emile")]
+ [InlineData("Saint-Exupéry", "saint exupery")]
+ [InlineData("L'Écume", "l ecume")]
+ [InlineData("", "")]
+ [InlineData(null, "")]
+ public void La_normalisation_met_a_plat_casse_accents_et_ponctuation(string? brut, string attendu)
+ => Assert.Equal(attendu, NormalisationTexte.Normaliser(brut));
+
+ ///
+ /// La ponctuation devient une espace et ne disparaît pas : sans ça « P.F. » donnerait un seul
+ /// mot et les initiales ne pourraient plus s'aligner.
+ ///
+ [Fact]
+ public void La_ponctuation_separe_les_mots()
+ => Assert.Equal(["p", "f", "hamilton"], NormalisationTexte.Mots("P.F. Hamilton"));
+}