Ramener les ligatures « œ » et « æ » à « oe » et « ae »

L'Œuvre de Zola, que la BnF écrit avec la ligature, était introuvable à
qui tape « oeuvre » : NFD sépare les accents mais laisse les ligatures.

Table explicite plutôt que NFKD, qui aurait aussi transformé les exposants,
les chiffres romains et les espaces insécables. Une normalisation de
recherche doit rester prévisible.

Sept colonnes normalisées en dépendent, dont cinq portent une unicité. Le
rattrapage au démarrage est donc étendu aux envies, séries, revues et
numéros — qui ne calculaient leurs formes qu'à l'écriture — et refuse toute
collision AVANT d'écrire : sinon l'exception tomberait au démarrage et le
serveur ne se lancerait plus.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mathieu
2026-08-19 22:25:59 +02:00
co-authored by Claude Opus 5
parent ca0a9dc18c
commit b97cf33494
7 changed files with 293 additions and 36 deletions
+39
View File
@@ -797,6 +797,45 @@ Deux formes cohabitent sur `Auteur`, et ce n'est pas une redondance :
La clé ne sait pas rapprocher les **initiales** (`P.F. Hamilton``Peter F. Hamilton`) : cela demande d'aligner les mots un à un, ce qui n'a pas d'équivalent SQL. Ce cas est traité en mémoire par `RapprochementAuteurs.Comparer`, sur une table qui compte au plus quelques centaines de lignes.
### Ligatures : « œ » vaut « oe » — corrigé le 2026-08-19
`NormalisationTexte` décompose en **NFD**, qui sépare les accents mais **laisse les ligatures
intactes** : `L'Œuvre` et `L'oeuvre` ne se rencontraient jamais. Zola a écrit *L'Œuvre*, que la
BnF orthographie avec la ligature — le livre était donc introuvable à qui tape « oeuvre », et
n'apparaissait pas grisé dans la bibliographie de son auteur.
**Table explicite plutôt que NFKD**, qui aurait fait bien davantage : `²``2`, `Ⅻ``XII`,
espaces insécables, formes de présentation. Une normalisation de recherche doit rester
prévisible : on ne défait que ce dont le besoin est constaté, soit `œ`/`Œ` et `æ`/`Æ`.
⚠️ **La ligature vaut deux lettres, elle ne coupe pas le mot** : `cœur``coeur`, pas
`co eur`. La ponctuation, elle, sépare toujours (`P.F.``p f`).
#### Changer cette fonction déplace des invariants en base
C'est ce qui avait fait repousser la correction, et le risque était réel : **sept colonnes
normalisées** en dépendent, dont **trois portent une unicité** (`Auteur.CleRegroupement`,
`LivreSouhaite (Utilisateur, TitreNormalise, AuteurNormalise)`, `Serie.TitreNormalise`,
`Revue.TitreNormalise`, `NumeroRevue (RevueId, NumeroNormalise)`).
`ServiceRenormalisation` a donc été étendu aux tables apparues après lui — envies, séries,
revues, numéros —, qui calculaient leurs formes **à l'écriture** et que rien ne rattrapait.
⚠️ **Une collision d'unicité au démarrage empêcherait le serveur de se lancer.** Si la nouvelle
règle rend identiques deux lignes qui ne l'étaient pas (« L'Œuvre » et « L'oeuvre »), l'écriture
échoue — et elle échoue *avant* que l'application ne réponde. Le service détecte donc la
collision avant d'écrire : **la ligne la plus ancienne prend la nouvelle forme, les suivantes
gardent l'ancienne**, et un avertissement est journalisé. Mal normalisée vaut infiniment mieux
que supprimée, ou qu'un serveur mort.
Le jeu des clés occupées part de l'état **actuel** de la base, et chaque ligne **libère la
sienne** avant de réserver la nouvelle : sans cette libération, toute ligne verrait sa nouvelle
valeur « déjà prise » dès qu'elle égale son ancienne, et rien ne serait jamais corrigé. Deux
tests couvrent le cas de collision et l'idempotence.
Les auteurs, eux, sont **fusionnés** et non laissés en double : c'est le comportement déjà en
place pour les variantes d'accent, et il vaut pour les ligatures sans rien changer.
### Reprise des données existantes
La migration recopie l'ancien contenu avec les moyens du bord, puis **`ServiceRenormalisation` finit le travail en C# au démarrage** — idempotent, il sert aussi de filet si les règles de normalisation changent un jour.
-20
View File
@@ -16,26 +16,6 @@ Le travail visuel de fond (couleurs, typographie, mise en page) est repoussé vo
---
## Recherche : la ligature « œ » n'est pas réduite à « oe »
Repéré le 2026-08-18 en implémentant la liste d'envies. `NormalisationTexte.Normaliser`
décompose en **NFD**, qui sépare les accents mais laisse les ligatures intactes — seul NFKD les
défait. Conséquence : `L'Œuvre` et `L'oeuvre` ne se rencontrent jamais.
Ce n'est pas propre à la liste d'envies : la même fonction alimente la **recherche du catalogue**
et la **clé unique des auteurs**. Zola a écrit *L'Œuvre*, que la BnF orthographie avec la
ligature ; un utilisateur qui tape « oeuvre » ne le trouvera pas, et ne verra pas non plus le
livre grisé dans la bibliographie.
Correction envisagée : passer en NFKD, ou traiter explicitement `œ`/`Œ` et `æ`/`Æ`. **Non fait
volontairement** — changer cette fonction déplace deux invariants en base (les colonnes
normalisées et l'index unique `CleRegroupement`). `ServiceRenormalisation` sait recalculer les
colonnes au démarrage, mais la fusion d'auteurs que la nouvelle règle provoquerait mérite d'être
regardée avant. Documenté par un test (`CleOeuvreTests.Ne_reduit_pas_la_ligature_oe`) pour que
la limite reste visible.
---
## Bibliographie : ce qui reste à creuser
La bibliographie par auteur est implémentée (voir `CLAUDE.md`). Deux pistes non traitées :
+119 -3
View File
@@ -25,15 +25,131 @@ public sealed class ServiceRenormalisation(MaBibliDbContext db, ILogger<ServiceR
{
var titres = await RenormaliserTitresAsync(ct);
var (auteursCorriges, auteursFusionnes) = await RenormaliserAuteursAsync(ct);
var autres = await RenormaliserLesAutresTablesAsync(ct);
if (titres + auteursCorriges + auteursFusionnes > 0)
if (titres + auteursCorriges + auteursFusionnes + autres > 0)
{
logger.LogInformation(
"Renormalisation : {Titres} titre(s), {Auteurs} auteur(s) corrigés, {Fusions} fusionné(s).",
titres, auteursCorriges, auteursFusionnes);
"Renormalisation : {Titres} titre(s), {Auteurs} auteur(s) corrigés, "
+ "{Fusions} fusionné(s), {Autres} autre(s) ligne(s).",
titres, auteursCorriges, auteursFusionnes, autres);
}
}
/// <summary>
/// Remet à jour les colonnes normalisées des tables apparues après ce service.
/// </summary>
/// <remarks>
/// Envies, séries, revues et numéros calculent leurs formes <b>à l'écriture</b> : rien ne
/// les rattraperait si la règle de normalisation changeait — et elle a changé le 2026-08-19,
/// avec le développement des ligatures <c>œ</c> et <c>æ</c>. Sans ce passage, une envie
/// enregistrée avant serait restée introuvable par sa propre clé.
/// <para>
/// ⚠️ Trois de ces colonnes portent une <b>unicité</b>. Une règle plus large peut donc
/// rendre identiques deux lignes qui ne l'étaient pas (« L'Œuvre » et « L'oeuvre »), et
/// l'écriture échouerait <b>au démarrage</b> — l'application ne se lancerait plus. La
/// collision est donc détectée avant d'écrire : la ligne la plus ancienne prend la nouvelle
/// clé, les suivantes <b>gardent l'ancienne</b> et sont journalisées. Mal normalisée est
/// infiniment préférable à supprimée, ou à un serveur qui ne démarre pas.
/// </para>
/// </remarks>
private async Task<int> RenormaliserLesAutresTablesAsync(CancellationToken ct)
{
var corriges = 0;
var souhaits = await db.LivresSouhaites.OrderBy(s => s.Id).ToListAsync(ct);
corriges += Renormaliser(
souhaits,
s => Cle(s.Utilisateur, s.TitreNormalise, s.AuteurNormalise),
s => Cle(s.Utilisateur, CleOeuvre.Cle(s.Titre), RapprochementAuteurs.Cle(s.Auteur)),
s => s.RecalculerFormes(),
s => $"l'envie « {s.Titre} »");
var series = await db.Series.OrderBy(s => s.Id).ToListAsync(ct);
corriges += Renormaliser(
series,
s => s.TitreNormalise,
s => NormalisationTexte.Normaliser(s.Titre),
s => s.RecalculerFormes(),
s => $"la série « {s.Titre} »");
var revues = await db.Revues.OrderBy(r => r.Id).ToListAsync(ct);
corriges += Renormaliser(
revues,
r => r.TitreNormalise,
r => NormalisationTexte.Normaliser(r.Titre),
r => r.RecalculerFormes(),
r => $"la revue « {r.Titre} »");
var numeros = await db.NumerosRevue.OrderBy(n => n.Id).ToListAsync(ct);
corriges += Renormaliser(
numeros,
n => Cle(n.RevueId.ToString(), n.NumeroNormalise),
n => Cle(n.RevueId.ToString(), NormalisationTexte.Normaliser(n.Numero)),
n => n.RecalculerFormes(),
n => $"le numéro {n.Numero}");
if (corriges > 0)
{
await db.SaveChangesAsync(ct);
}
return corriges;
}
/// <summary>
/// Recalcule les formes d'un ensemble de lignes en refusant toute collision d'unicité.
/// </summary>
/// <remarks>
/// Le jeu des clés occupées part de l'état <b>actuel</b> de la base, et chaque ligne libère
/// la sienne avant de réserver la nouvelle. Sans cette libération, une ligne inchangée
/// bloquerait sa propre mise à jour ; sans le jeu initial, une ligne traitée tôt prendrait
/// la clé d'une ligne non encore traitée, et l'écriture échouerait.
/// </remarks>
private int Renormaliser<T>(
IReadOnlyList<T> lignes,
Func<T, string> cleActuelle,
Func<T, string> cleVoulue,
Action<T> appliquer,
Func<T, string> nommer)
{
var occupees = new HashSet<string>(lignes.Select(cleActuelle), StringComparer.Ordinal);
var corriges = 0;
foreach (var ligne in lignes)
{
var actuelle = cleActuelle(ligne);
var voulue = cleVoulue(ligne);
if (actuelle == voulue)
{
continue;
}
occupees.Remove(actuelle);
if (!occupees.Add(voulue))
{
// Deux lignes que la nouvelle règle rend identiques. On garde les deux, en
// laissant la seconde sur son ancienne clé : c'est visible, réparable à la main,
// et surtout ça ne coûte pas le démarrage du serveur.
occupees.Add(actuelle);
logger.LogWarning(
"Renormalisation : {Ligne} garde son ancienne forme, la nouvelle est déjà prise.",
nommer(ligne));
continue;
}
appliquer(ligne);
corriges++;
}
return corriges;
}
/// <summary>Clé composite comparable, les composants ne pouvant pas contenir de NUL.</summary>
private static string Cle(params string?[] composants) => string.Join('\0', composants);
private async Task<int> RenormaliserTitresAsync(CancellationToken ct)
{
var livres = await db.Livres.ToListAsync(ct);
@@ -27,6 +27,18 @@ public static class NormalisationTexte
/// La ponctuation devient une espace plutôt que de disparaître : « P.F. Hamilton » doit
/// donner trois mots (<c>p f hamilton</c>) et non un seul (<c>pf hamilton</c>), sans quoi
/// le rapprochement par initiales serait impossible.
/// <para>
/// Les <b>ligatures</b> <c>œ</c> et <c>æ</c> sont décomposées en <c>oe</c> et <c>ae</c>.
/// ⚠️ Ce n'est pas ce que fait la décomposition NFD, qui sépare les accents mais laisse les
/// ligatures intactes : sans ce traitement explicite, <i>L'Œuvre</i> de Zola — que la BnF
/// écrit avec la ligature — resterait introuvable à qui tape « oeuvre » au clavier.
/// </para>
/// <para>
/// Le choix s'est porté sur une table explicite plutôt que sur NFKD, qui aurait fait bien
/// davantage : « ² » → « 2 », « Ⅻ » → « XII », les espaces insécables, les formes de
/// présentation. Une normalisation de recherche doit rester prévisible ; on ne défait ici que
/// ce dont on a constaté le besoin.
/// </para>
/// </remarks>
public static string Normaliser(string? brut)
{
@@ -47,6 +59,18 @@ public static class NormalisationTexte
continue; // Accent détaché par la décomposition.
}
if (Ligature(c) is { } lettres)
{
if (espaceEnAttente && sortie.Length > 0)
{
sortie.Append(' ');
}
espaceEnAttente = false;
sortie.Append(lettres);
continue;
}
if (char.IsLetterOrDigit(c))
{
if (espaceEnAttente && sortie.Length > 0)
@@ -66,6 +90,21 @@ public static class NormalisationTexte
return sortie.ToString().Normalize(NormalizationForm.FormC);
}
/// <summary>
/// Développement d'une ligature, ou <c>null</c> si le caractère n'en est pas une.
/// </summary>
/// <remarks>
/// Volontairement limité à <c>œ</c> et <c>æ</c>, seules ligatures courantes du français.
/// Les ligatures typographiques de présentation (<c>fi</c>, <c>fl</c>) ne sont pas traitées :
/// elles n'apparaissent pas dans les catalogues, et rien n'a montré le besoin.
/// </remarks>
private static string? Ligature(char c) => c switch
{
'œ' or 'Œ' => "oe",
'æ' or 'Æ' => "ae",
_ => null,
};
/// <summary>Mots de la forme normalisée, sans mot vide.</summary>
public static string[] Mots(string? brut)
{
+7 -13
View File
@@ -40,23 +40,17 @@ public class CleOeuvreTests
CleOeuvre.Cle("Nouvelles : second recueil"));
/// <summary>
/// Limite <b>héritée</b> de <see cref="NormalisationTexte"/>, pas propre à la clé d'œuvre :
/// la ligature « œ » n'est pas ramenée à « oe ».
/// La ligature « œ » est ramenée à « oe » — corrigé le 2026-08-19.
/// </summary>
/// <remarks>
/// <c>NormalisationTexte</c> décompose en NFD, qui sépare les accents mais laisse les
/// ligatures intactes — seul NFKD les défait. Conséquence concrète : <i>L'Œuvre</i> de Zola,
/// que la BnF écrit avec la ligature, n'est pas rapproché d'un « L'oeuvre » saisi au clavier.
/// <para>
/// Le défaut ne vient pas d'ici et ne se corrige pas ici : la même fonction alimente la
/// recherche du catalogue et la clé unique des auteurs, et en changer déplacerait ces deux
/// invariants. Consigné dans IDEES.md ; ce test existe pour que la limite soit visible plutôt
/// que découverte en usage.
/// </para>
/// Le comportement vient de <see cref="NormalisationTexte"/> et non d'ici : la décomposition
/// NFD sépare les accents mais laisse les ligatures intactes, d'où une table explicite.
/// Sans elle, <i>L'Œuvre</i> de Zola — que la BnF écrit avec la ligature — restait
/// introuvable à qui tape « oeuvre » au clavier, et n'était pas grisé dans sa bibliographie.
/// </remarks>
[Fact]
public void Ne_reduit_pas_la_ligature_oe() =>
Assert.NotEqual(CleOeuvre.Cle("L'Œuvre"), CleOeuvre.Cle("L'oeuvre"));
public void Reduit_la_ligature_oe() =>
Assert.Equal(CleOeuvre.Cle("L'Œuvre"), CleOeuvre.Cle("L'oeuvre"));
/// <summary>
/// Limite assumée : un titre retraduit n'est pas rapproché du tout.
@@ -106,6 +106,12 @@ public class RapprochementAuteursTests
[InlineData(" Zola, Émile ", "zola emile")]
[InlineData("Saint-Exupéry", "saint exupery")]
[InlineData("L'Écume", "l ecume")]
// Ligatures : NFD ne les défait pas, une table explicite s'en charge (2026-08-19).
[InlineData("L'Œuvre", "l oeuvre")]
[InlineData("Sœur Emmanuelle", "soeur emmanuelle")]
[InlineData("Ex æquo", "ex aequo")]
// La ligature vaut deux lettres, elle ne coupe donc PAS le mot en deux.
[InlineData("cœur", "coeur")]
[InlineData("", "")]
[InlineData(null, "")]
public void La_normalisation_met_a_plat_casse_accents_et_ponctuation(string? brut, string attendu)
@@ -186,6 +186,89 @@ public class ServiceRenormalisationTests : IDisposable
Assert.Empty(_db.Livres);
}
// ── Rattrapage des tables plus récentes ─────────────────────────────────
//
// Envies, séries, revues et numéros calculent leurs formes À L'ÉCRITURE : sans ce passage,
// un changement de règle de normalisation — comme le développement des ligatures le
// 2026-08-19 — laisserait ces lignes sur leur ancienne clé, donc introuvables par la
// nouvelle. Trois de ces colonnes portent en plus une UNICITÉ, et une collision se paierait
// au démarrage du serveur.
[Fact]
public async Task Une_envie_ecrite_avant_le_changement_de_regle_est_rattrapee()
{
// Forme telle qu'elle aurait été calculée avant le développement des ligatures.
_db.LivresSouhaites.Add(new LivreSouhaite
{
Utilisateur = "mathieu",
Titre = "L'Œuvre",
TitreNormalise = "l œuvre",
Auteur = "Émile Zola",
AuteurNormalise = "emile zola",
});
await _db.SaveChangesAsync();
await ExecuterAsync();
Assert.Equal("l oeuvre", _db.LivresSouhaites.Single().TitreNormalise);
}
[Fact]
public async Task Une_serie_et_une_revue_sont_rattrapees_aussi()
{
_db.Series.Add(new Serie { Titre = "Les Sœurs", TitreNormalise = "les sœurs" });
_db.Revues.Add(new Revue { Titre = "Cœur", TitreNormalise = "cœur" });
await _db.SaveChangesAsync();
await ExecuterAsync();
Assert.Equal("les soeurs", _db.Series.Single().TitreNormalise);
Assert.Equal("coeur", _db.Revues.Single().TitreNormalise);
}
/// <summary>
/// ⚠️ Le cas qui empêcherait le serveur de démarrer.
/// </summary>
/// <remarks>
/// La nouvelle règle rend identiques deux séries qui ne l'étaient pas. L'index unique
/// refuserait l'écriture, et l'exception surviendrait <b>au démarrage</b> : l'application ne
/// se lancerait plus du tout. La plus ancienne prend la nouvelle forme, la suivante garde
/// l'ancienne — mal normalisée vaut infiniment mieux que supprimée, ou qu'un serveur mort.
/// </remarks>
[Fact]
public async Task Une_collision_dunicite_ne_fait_pas_echouer_le_demarrage()
{
_db.Series.Add(new Serie { Titre = "L'Œuvre", TitreNormalise = "l œuvre" });
_db.Series.Add(new Serie { Titre = "L'oeuvre", TitreNormalise = "l oeuvre" });
await _db.SaveChangesAsync();
await ExecuterAsync();
var series = _db.Series.OrderBy(s => s.Id).ToList();
Assert.Equal(2, series.Count);
Assert.Equal(["l oeuvre", "l œuvre"], series.Select(s => s.TitreNormalise).Order());
}
/// <summary>
/// Une ligne déjà correcte ne doit pas se bloquer elle-même.
/// </summary>
/// <remarks>
/// Le jeu des clés occupées part de l'état actuel de la base : sans la libération de sa
/// propre clé, toute ligne dont la forme change verrait sa nouvelle valeur « déjà prise »
/// dès qu'elle est égale à son ancienne — et rien ne serait jamais corrigé.
/// </remarks>
[Fact]
public async Task Le_rattrapage_reste_idempotent()
{
_db.Revues.Add(new Revue { Titre = "Cœur", TitreNormalise = "cœur" });
await _db.SaveChangesAsync();
await ExecuterAsync();
await ExecuterAsync();
Assert.Equal("coeur", _db.Revues.Single().TitreNormalise);
}
public void Dispose()
{
_db.Dispose();