using System.Globalization; using System.Text.RegularExpressions; namespace MaBibli.Api.Services.Isbn; /// /// Nettoyage de la ponctuation ISBD des champs Dublin Core de la BnF. /// /// /// Les champs BnF ne sont pas exploitables bruts. Règles validées le 2026-08-17 (CLAUDE.md) : /// /// dc:title : Germinal / Émile Zola ; préface…Germinal /// dc:creator : Zola, Émile (1840-1902). Auteur du texteÉmile Zola /// dc:publisher : le Livre de poche (Paris)le Livre de poche /// /// Les valeurs déjà propres doivent traverser ces règles inchangées. /// public static partial class NettoyageIsbd { [GeneratedRegex(@"\s*\([^)]*\)")] private static partial Regex Parentheses(); [GeneratedRegex(@"\s*\([^)]*\)\s*$")] private static partial Regex ParenthesesFinales(); /// /// Un nombre collé à une mention de pages. Le nombre doit précéder « p. » : dans /// « p. 45-90 », les chiffres décrivent un intervalle dans un volume, pas son épaisseur. /// [GeneratedRegex(@"(? /// Titre : couper à la première mention de responsabilité (« / »). /// public static string? Titre(string? brut) { var s = brut?.Trim(); if (string.IsNullOrEmpty(s)) { return null; } var i = s.IndexOf(" / ", StringComparison.Ordinal); if (i >= 0) { s = s[..i]; } s = s.Trim(); return s.Length == 0 ? null : s; } /// /// Retire du titre le nom d'auteur qui y est collé en fin de chaîne. /// /// /// Cas réel mesuré, ISBN 9782749942636 : /// dc:title vaut « Le plus grand défi … (Nouvelle éd. augmentée) Aurélien Barrau » /// alors que dc:creator vaut « Barrau, Aurélien (1973-....) ». Le nom est recollé /// sans le séparateur ISBD « / », donc ne peut rien y faire : la /// notice est sale à la source. /// /// L'heuristique est volontairement stricte — comparaison en fin de chaîne uniquement, /// et seulement contre un auteur déjà nettoyé. Un titre qui contient légitimement un nom de /// personne ailleurs (« Lettre à Émile Zola ») n'est pas touché, et un titre qui est /// le nom de l'auteur (biographie, recueil) est laissé intact plutôt que réduit à rien. /// /// public static string? RetirerAuteurEnFin(string? titre, IEnumerable auteurs) { var s = titre?.Trim(); if (string.IsNullOrEmpty(s)) { return null; } foreach (var auteur in auteurs) { var nom = auteur?.Trim(); if (string.IsNullOrEmpty(nom) || nom.Length >= s.Length) { // Un nom aussi long que le titre ne laisserait rien : c'est une biographie // ou un recueil, pas une notice sale. continue; } var debut = s.Length - nom.Length; // IgnoreNonSpace ignore les accents sans changer les longueurs, contrairement à une // normalisation NFD : les indices calculés restent valables sur la chaîne d'origine. var identique = string.Compare( s, debut, nom, 0, nom.Length, CultureInfo.InvariantCulture, CompareOptions.IgnoreCase | CompareOptions.IgnoreNonSpace) == 0; if (!identique) { continue; } // Le nom doit commencer sur une frontière de mot, sinon « …Barrau » couperait aussi // dans un mot qui se termine par les mêmes lettres. if (char.IsLetterOrDigit(s[debut - 1])) { continue; } var reste = s[..debut].TrimEnd(' ', ',', ';', '.', '-', '/', ':'); if (reste.Length > 0) { return reste; } } return s; } /// /// Auteur : retirer les dates entre parenthèses, le rôle qui suit le point, /// puis inverser « Nom, Prénom » en « Prénom Nom ». /// public static string? Auteur(string? brut) { var s = brut?.Trim(); if (string.IsNullOrEmpty(s)) { return null; } // 1. Dates de vie et autres qualificatifs entre parenthèses : (1840-1902), (1932-....) s = Parentheses().Replace(s, string.Empty).Trim(); // 2. Rôle après le point : « . Auteur du texte », « . Préfacier ». var pointRole = IndexDuPointDeRole(s); if (pointRole >= 0) { // Quand le rôle suit une initiale (« Cormen, Thomas H. Auteur du texte »), le point // qui sert de séparateur appartient aussi au nom : on le conserve. s = EstPointDInitiale(s, pointRole) ? s[..(pointRole + 1)] : s[..pointRole]; } s = RetirerPonctuationFinale(s); // 3. Inversion « Nom, Prénom » → « Prénom Nom » (sur la première virgule seulement). var virgule = s.IndexOf(", ", StringComparison.Ordinal); if (virgule > 0) { var nom = s[..virgule].Trim(); var prenom = s[(virgule + 2)..].Trim(); if (nom.Length > 0 && prenom.Length > 0) { s = $"{prenom} {nom}"; } } s = s.Trim(); return s.Length == 0 ? null : s; } /// /// Éditeur : retirer la ville entre parenthèses en fin de chaîne. /// public static string? Editeur(string? brut) { var s = brut?.Trim(); if (string.IsNullOrEmpty(s)) { return null; } s = ParenthesesFinales().Replace(s, string.Empty).Trim(); return s.Length == 0 ? null : s; } /// /// Nombre de pages lu dans la description matérielle (dc:format), ou null. /// /// /// Ce champ n'est pas un nombre, c'est une phrase : la BnF y décrit le support /// (« 1 vol. (113 p.) : ill., couv. ill. en coul. ; 18 cm », « 503 p. : couv. ill. ; 17 cm », /// mesuré sur les notices enregistrées sous Fixtures/). Les règles restent /// volontairement étroites : on ne reconnaît qu'un nombre suivi de « p. » ou de /// « page(s) », et tout le reste rend null — le catalogue décrit aussi des /// disques compacts et des dimensions en centimètres. /// /// ⚠️ Le sens de l'erreur n'est pas symétrique : un champ vide se remplit à la main en trois /// secondes, un chiffre faux s'enregistre sans que personne ne le remarque. C'est pourquoi /// « 1 vol. » ne rend pas 1, « 30 cm » ne rend pas 30, et « (p. 45-90) » — une pagination de /// contribution, pas une épaisseur — ne rend rien du tout. /// /// /// La valeur reste de toute façon proposée dans un champ modifiable, jamais imposée. /// /// public static int? NombreDePages(string? brut) { if (string.IsNullOrWhiteSpace(brut)) { return null; } var trouve = Pagination().Match(brut); if (!trouve.Success) { return null; } if (!int.TryParse( trouve.Groups[1].Value, NumberStyles.None, CultureInfo.InvariantCulture, out var pages)) { // Un nombre à rallonge ne tient pas dans un int : ce n'est pas une pagination. return null; } // Bornes de bon sens. Zéro page n'est pas une donnée, et au-delà de dix mille on a lu // autre chose que l'épaisseur d'un livre. return pages is > 0 and <= 10_000 ? pages : null; } /// /// Titre d'un périodique : couper la mention de responsabilité puis retirer le /// qualificatif entre parenthèses. /// /// /// Mesuré sur l'ISSN 2466-6718 le 2026-08-18 : la BnF donne /// « Médor (Bruxelles) ». Le parenthésé n'est pas une ville d'édition mais le /// qualificatif de désambiguïsation propre aux notices de revues — deux périodiques /// peuvent porter le même titre. Il se retire comme la ville d'un éditeur, et pour la même /// raison : ce qu'on montre à l'utilisateur doit ressembler à ce qui est écrit sur la /// couverture du magazine qu'il tient en main. /// public static string? TitrePeriodique(string? brut) => Editeur(Titre(brut)); /// /// Retire les virgules et espaces de fin, et le point final sauf s'il appartient /// à une initiale (« Cormen, Thomas H. » doit garder son point). /// private static string RetirerPonctuationFinale(string s) { s = s.TrimEnd(' ', ','); while (s.EndsWith('.')) { if (EstPointDInitiale(s, s.Length - 1)) { break; } s = s[..^1].TrimEnd(' ', ','); } return s; } /// /// Position du point qui introduit le rôle, ou -1. /// /// /// Un point suivi d'une espace sépare le rôle du nom. Le point d'une initiale /// (« Cormen, Thomas H. ») n'en est pas un — le couper amputerait le prénom. /// /// Mais protéger l'initiale ne doit pas protéger le rôle qui la suit : « Cormen, Thomas H. /// Auteur du texte » renvoyait auparavant « Thomas H. Auteur du texte Cormen ». Ce qui /// tranche : dans « Nom, Prénom », la seule chose qui puisse suivre une initiale et faire /// encore partie du nom est une autre initiale (« Tolkien, J. R. R. »). Tout le /// reste est le rôle. /// /// private static int IndexDuPointDeRole(string s) { for (var i = 0; i < s.Length - 1; i++) { if (s[i] != '.' || s[i + 1] != ' ') { continue; } if (EstPointDInitiale(s, i) && CommenceParUneInitiale(s, i + 2)) { continue; } return i; } return -1; } /// /// Le point en clôt-il une initiale (majuscule isolée) ? /// private static bool EstPointDInitiale(string s, int i) => i >= 1 && char.IsUpper(s[i - 1]) && (i == 1 || s[i - 2] == ' ' || s[i - 2] == '-' || s[i - 2] == '\''); /// /// Le texte à partir de commence-t-il par une initiale /// (une majuscule suivie d'un point ou de la fin de chaîne) ? /// private static bool CommenceParUneInitiale(string s, int debut) => debut < s.Length && char.IsUpper(s[debut]) && (debut + 1 == s.Length || s[debut + 1] == '.'); }