jeudi 19 novembre 2015

Ignorer la casse, les diacritiques, et les ligatures dans un autocompléteur GWT

Autant, en Java, supprimer les diacritiques et ligatures est un jeu d'enfant, il suffit de ces quelques ligne (voir ci-dessous), autant, dans une application Web avec GWT, ce n'est pas aussi immédiat.
Nous allons voir comment réaliser cela dans un autocompléteur grâce auquel l'utilisateur peut sélectionner le nom d'un pays sans se soucier de commencer à l'écrire : avec ou sans accents, en majuscules ou minuscules.


Donc, en pur Java cela donnerait :
public static String removeDiacritics(String input) {
    // Normalizer.normalise() converts each accented
    // character into 1 non-accented character followed
    // by 1 or more characters representing the accent(s)
    // alone. These characters representing only
    // an accent belong to the Unicode category
    // CombiningDiacriticalMarks. The call to replaceAll
    // strips out all characters in that category.
    String normalized = Normalizer.normalize(input, Form.NFKD);
    String cleared = normalized
        .replaceAll("\\p{InCombiningDiacriticalMarks}+", "");
    return cleared;
}
  • Le premier problème, quand on passe au monde Web, est que l'émulateur GWT ne connait pas la classe Normalizer et que les navigateurs ne connaissent pas les propriétés de block Unicode.
  • Le second est de trouver où substituer la recherche du texte saisi par l'utilisateur dans l'autocompléteur par un texte alternatif sans accents ni ligatures.

Effet souhaité



Plutôt qu'un long discours, l'idée est d'obtenir ceci :
Séquence obtenue en quelques centaines de millisecondes après la saisie des premiers caractères

Si on clique dans la zone de saisie pour taper du texte, on obtient la liste des pays (image de gauche); en saisissant les premiers caractères, la liste se réduit (au milieu); puis le texte est substitué par le premier item possible (à droite). L'utilisateur peut valider ou continuer à saisir des caractères ou utiliser les flêches pour sélectionner l'item de son choix.

Dans cet exemple, les caractères saisis sont sans accents et en minuscule, mais le même effet serait obtenu avec des caractères accentués et/ou en majuscule.

Vous pouvez vous-même expérimenter le widget ici.

La conversion d'une chaîne en caractères latins



Comme dit précédemment, Javascript dans le browser ne permet pas de traiter les diacritiques. Quelques écrits mentionnent des solutions plus ou moins viables et complètes, et j'ai jeté mon dévolu sur celle décrite ici.

Ce document précise que bien que le format Unicode définisse une table de décomposition des caractères accentués, les ligatures (AE / OE) y sont absentes. Pour créer sa table de mapping, l'auteur a recouru à un script Perl qui contient la liste des noms des caractères à mapper.

Retour à GWT. La solution paraît bonne, il suffit de la transposer en pur Java. Je commence par importer la librairie ICU (International Components for Unicode) car les librairies de base Java ne savent pas obtenir un caractère depuis son nom :

    <dependency>
        <groupId>com.ibm.icu</groupId>
        <artifactId>icu4j</artifactId>
        <version>56.1</version>
        <scope>compile</scope>
    </dependency>

Il n'est évidemment pas question de compiler cela en Javascript, mais simplement d'utiliser ICU pour créer la table des caractères à substituer. GWT sait faire cela très bien avec les générateurs.

Nous disposons donc de la liste des noms Unicode des caractères à transformer :

LATIN CAPITAL LETTER A
LATIN CAPITAL LETTER A WITH ACUTE
...
LATIN SUBSCRIPT SMALL LETTER V
LATIN SUBSCRIPT SMALL LETTER X

... il y en a tout de même plus de 1000 (liste complète ici).

L'idée est donc d'utiliser dans le générateur la librairie ICU pour retrouver un caractère à partir de son nom : UCharacter.getCharFromName(), puis -toujours à partir de son nom- trouver par quoi le substituer.

Par exemple, à partir de "LATIN CAPITAL LETTER A WITH ACUTE", on arrive à obtenir le mapping "Á" -> "A", à partir de "LATIN CAPITAL LIGATURE OE" le mapping "Œ" -> "OE" et ainsi de suite. L'élégance de cette solution est que le nom contient à la fois une référence au caractères (code point) Unicode et par quoi il faut le remplacer (mis en gras) qu'on peut retrouver par le biais de judicieuses expressions régulières.

Pour convertir une chaîne dans notre application Web, le code Java se résume à ceci :

public abstract class LatinMapper {

    public static String latinize(String string) {
        StringBuilder result = new StringBuilder();
        for (int codePoint : unicodeCodePoints(string)) {
            String alt = new String(Character.toChars(codePoint));
            JSONValue val = MAP.get(alt);
            if (val != null) {
                alt = val.isString().stringValue();
            }
            result.append(alt);
        }
        return result.toString();
    }

    private static JSONObject MAP = map().isObject();

    protected static native JSONValue map() /*-{
        var o = {
              'Á': 'A', // LATIN CAPITAL LETTER A WITH ACUTE
              'Ă': 'A', // LATIN CAPITAL LETTER A WITH BREVE
              // ...
              'ᵥ': 'v', // LATIN SUBSCRIPT SMALL LETTER V
              'ₓ': 'x', // LATIN SUBSCRIPT SMALL LETTER X
        };
        return @com.google.gwt.json.client.JSONObject::new(Lcom/google/gwt/core/client/JavaScriptObject;)(o);
    }-*/;

}

La méthode unicodeCodePoints() n'est pas affichée ici pour économiser de la place car elle ne présente que peu d'intérêt : chacun sait qu'un "code point" Unicode peut couvrir 1 ou 2 caractères et GWT supporte ces fonctions très bien.

La partie intéressante est celle qui crée un objet natif Javascript avec tous les mappings. Comme il y en a plus de 1000, c'est le générateur qui va se charger de surcharger cette méthode avec la liste complète obtenue depuis les noms.

D'abord la déclaration du générateur :

<generate-with
        class="com.github.alternet.demo.countries
                         .generator.LatinMapperGenerator">
    <when-type-assignable
        class="com.github.alternet.demo.countries
                                    .client.LatinMapper"/>
</generate-with>

... ensuite il reste quelques adaptations à faire dans LatinMapper : la méthode native va devenir non statique de manière à être surchargeable, et la variable "MAP" va être initialisée à partir de l'instance générée :

    private static JSONObject MAP;

    static {
        LatinMapper mapper = GWT.create(LatinMapper.class);
        MAP = mapper.map().isObject();
    }

Le code complet du générateur et de la classe LatinMapper sont disponibles dans GitHub.

Le widget autocompléteur



J'ai utilisé Sencha GXT qui dispose d'un autocompléteur qu'il nous suffit d'adapter.

Tout d'abord, nos données :

{   "AF":"Afghanistan",
    "ZA":"Afrique du Sud",
    "AL":"Albanie",
    "DZ":"Alg\u00e9rie",
    "DE":"Allemagne",
    ...
}

... et le modèle de données correspondant (code complet ici):

public class Country {

    private String code;
    private String name;

    public Country() {}

    public Country(String code, String name) {
        this.code = code;
        this.name = name;
    }

    // + getters and setters
}

Il suffit de charger les données dans un magasin :

    public ListStore<Country> loadCountries() {
        final ListStore<Country> countries = new ListStore<Country> (...)

        ...

        // launch an HTTP request with a callback that parse
        // the JSON result :
        if (200 == response.getStatusCode()) {
            JSONValue val = JSONParser.parseLenient(response.getText());
            for (String code : val.isObject().keySet()) {
                countries.add(
                    new Country(
                        code,
                        val.isObject().get(code)
                           .isString().stringValue()));
            }
        }
    }

... on pourrait presque s'amuser à construire une liste des pays en dur comme pour les mappings de caractères, mais en général ceux-ci sont stockés en BD, ce qui est somme toute plus évolutif.

Il reste encore à déclarer les méthodes d'accès aux propriétés qui seront générées par GXT :

    interface CountryProperties extends PropertyAccess<Country> {
        ModelKeyProvider<Country> code();
        LabelProvider<Country> name();

        CountryProperties INSTANCE = GWT.create(CountryProperties.class);
    }

Et nous sommes prêts pour créer l'autocompléteur !

De ce côté là, la seule difficulté n'est pas de l'adapter, mais plutôt de trouver la bonne méthode à surcharger. Il m'a fallu quelques errements en debug mode pour repérer la fonction. Il suffit de surcharger la méthode ComboBoxCell.itemMatchesQuery() :

        ListStore<Country> countries = loadCountries();

        ComboBox<Country> autocompleter = new ComboBox<Country>(
            new ComboBoxCell<Country>(
                countries,
                CountryProperties.INSTANCE.name(),
                new LabelProviderSafeHtmlRenderer<Country>
                           (CountryProperties.INSTANCE.name())
            ) {
                @Override
                protected boolean itemMatchesQuery(Country item,
                                                    String query) {
                    String value = getPropertyEditor().render(item);
                    if (value != null) {
                        String latinValue = LatinMapper.latinize(value)
                                      .toLowerCase().replace('-', ' ');
                        String latinQuery = LatinMapper.latinize(query)
                                      .toLowerCase().replace('-', ' ');
                        return latinValue.startsWith(latinQuery);
                    }
                    return false;
                  }
            }
        );

Ici, je me paye le luxe de substituer en plus les tirets par des blancs. Toute autre adaptation en fonction des données à traiter peut être envisagée.

Voilà !

Le code du widget est ici.




Semantic Mismatch
ERR-19 : InheritenceException
Unable to create an instance
of both CUL and CHEMISE
-- See log for details



Aucun commentaire:

Enregistrer un commentaire