Files
BaC/content/2022-12-05-UTF8,-UTF16,-Rust-et-le-formulaire-web-magique.md
2025-02-27 12:52:48 +01:00

9.6 KiB
Raw Blame History

+++ title = "UTF-8, UTF-16, Rust et le formulaire Web magique" date = 2022-12-05 [taxonomies] tags = [ "utf-8", "utf-16", "rust" ] +++

Et là, la marmotte dUTF-16… attendez, je crois quon la déjà faite celle-là

Mon cerveau à deux heures du mat

Je suis tombé récemment sur une bizarrerie tellement étrange quil ma paru intéressant de tenter de la documenter (jai bien dit «tenter» parce que je ne suis pas certain que tout sera hyper précis dans ce que je vais dire…).

Un peu de contexte…

Je suis en train de développer un petit programme Rust dont lobjectif est de convertir un profil Twitter en profil Mastodon. Il faut donc récupérer la photo de profil, la bannière, si elle existe, les liens, etc… mais surtout le nom affiché par Twitter (le screen name à lopposé du name qui est le truc au bout de lURL Twitter) doit correspondre, le mieux possible au display name Mastodon.

Or il se trouve que sur Twitter, le fameux screen name contient 50 «caractères» (oui, je mets des air-guillemets, je texpliquerais pourquoi plus loin) alors que sur Mastodon, le display name est limité à 30 caractères. Cest comme ça sur lAPI et cest comme ça sur le formulaire permettant de le changer.

Il va donc falloir couper une chaîne de caractères…

Meme avec un chien découvrant qu’il va devoir couper une chaîne de caractères

Première approche naïve…

Prenons une chaîne de caractères «normales» (pas de caractères chelous, que de lASCII tout ce quil y a de plus vanilla) et voyons un peu comment on peut faire. La fonction truncate de la librairie standard permet de faire ça, ça fonctionne comme suit:

    let mut long_string = String::from("Je suis une longue phrase.");

    long_string.truncate(15);

    println!("{}", long_string);

Bon, là, ça «marche» dans ce cas précis et ça donne ça:

Je suis une lon

Sauf que la doc indique bien que la coupure va merder (panic, cest un peu la merde, on est bien daccord?) si par hasard on coupe au milieu dun caractère. Oui, parce que comme Rust fait de lUTF-8 partout, tout le temps, certains caractères sont sur plusieurs octets et en fait truncate ne coupe pas au caractère mais coupe à loctet… et panique. Essayons de le faire paniquer, tu vas voir que ce nest pas bien compliqué:

    let mut long_string = String::from("Je suis une très longue phrase.");

    long_string.truncate(15);

    println!("{}", long_string);

Voilà, perdu, on a coupé au mauvais endroit, plus rien ne fonctionne correctement:

thread 'main' panicked at 'assertion failed: self.is_char_boundary(new_len)', /rustc/897e37553bba8b42751c67658967889d11ecd120/library/alloc/src/string.rs:1280:13

Bah oui è est en fait deux octets en UTF-8: c3 et a8.

Découpage par rapport aux caractères

Il se trouve que la bibliothèque standard de Rust est une petite merveille lorsquil sagit daller chercher des fonctions très chelous permettant de faire des trucs parfois très tordus. Nous allons voir ce quil est possible de faire. La fonction chars permet de récupérer un intérateur sur un str (et donc par extension String) au caractère et non à loctet. Elle semble donc toute indiquée pour tenter de résoudre le problème que nous avons là.

Une première approche pourrait justement consister à itérer sur ce String et sarrêter au quinzième caractère:

    let long_string = String::from("Je suis une très longue phrase.");
    let mut shorter_string = String::new();

    for (i, c) in long_string.chars().enumerate() {
        if i >= 15 {
            break;
        }
        shorter_string.push(c);
    }

    println!("{}", shorter_string);

Bon là, ça marche effectivement:

Je suis une trè

Par contre, cest moyennement idiomatique, essayons de faire un peu mieux que ça en utilisant les fonctions take et collect:

    let long_string = String::from("Je suis une très longue phrase.");

    let shorter_string = long_string.chars().take(15).collect::<String>();

    println!("{}", shorter_string);

Même résultat, mais cest quand même un poil plus propre à la lecture et surtout, on évite davoir un 15 qui se balade dans le code sans quon puisse voir directement ce quil fait (et si taimes pas lopérateur turbofish, tu peux toujours préciser le type de variable au début, cest un peu comme tu le sens).

Bon ben ça marche, pourquoi tu nous casses les noix alors?

Alors, dabord, tu vas rester poli si tu veux pas bouffer ton cul et ensuite, attend, ce nétait que la première partie de la grosse marade. Oui, lobjectif final, cest de faire manger cette chaîne de caractères à Mastodon, via lAPI, mais en réalité, on peut aussi le voir directement sur linterface Web correspondante (elle a, en gros, les mêmes «limitations»).

Et pour ça, on va devoir se poser une autre question: quand un formulaire Web te dit quil est limité à 30 caractères, cest quels caractères? 30 caractères ASCII? 30 caractères Unicode (UTF-8)? Ou encore autre chose?

Commençons par le commencement et nous poser la question: en Rust, cest quoi la taille dune chaîne de caractères? Et bien, cest déjà pas si simple que ça…

    let long_string = String::from("Je suis une très longue phrase.");

    println!(
        "len: {}\ncount: {}",
        long_string.len(),
        long_string.chars().count()
    );

Renvoie:

len: 32
count: 31

Et ça na rien de choquant: len est supposé, encore une fois, donner la longueur en octets et non en caractères. Il faut donc encore une fois passer par chars et compter le nombre de caractères.

Sauf que (NDLR: le vrai fun commence maintenant), cest pas forcément le cas pour tous les caractères et ça dépend aussi de lencodage. Et là, normalement, tu dois commencer à saigner du nez: oui, si tu encodes en UTF-8 ou en UTF-16, bah, ça fait pas le même nombre de caractères…

Prenons des caractères plus exotiques, comme par exemple « 🇫🇷 ». Combien que ça fait de caractères ça, en Rust pur?

    let drapeau = String::from("🇫🇷");

    println!(
        "{}\nlen: {}\t count: {}",
        drapeau,
        drapeau.len(),
        drapeau.chars().count()
    );
🇫🇷
len: 8	 count: 2

Dawat? Donc, là, ça fait 2 caractères. Un seul caractère affiché mais deux caractères? En fait, cest «normal»: les drapeaux sont effectivement composés de deux caractères qui sont dans la table Unicode, U+1F1EB et U+1F1F7, identifiant chacun F et R respectivement. Cest parce que cest deux caractères sont côte à côte quun navigateur va lafficher comme un drapeau français. Sinon, il affichera simplement lidentifiant correspondant.

longueur_max = toto

Et là, normalement, tu te dis que cest bon, que tout va bien, que tu vas arriver à quelque chose… mais est-ce que les formulaires Web font de lUTF-8? Lattribut maxlength représente-t-il vraiment le nombre de caractères?

Allons faire le test pour vérifier, équipé de notre vaillant 🇫🇷:

30 caractères, mon cul oui !

Donc, cest supposément 30 caractères, on a vu quen UTF-8 le drapeau faisait 2 caractères et pourtant notre formulaire ne semble en accepter que 7 + le caractère F tout court à la fin. Quest-ce que la baise?

Et bien, en fait, les formulaires Web encodent en utilisant de lUTF-16 et non de lUTF-8 et ça donne effectivement plus de caractères. Essayons de voir comment retomber sur nos pieds en Rust. Pour cela, on va se servir de la fonction encode_utf16 qui va elle aussi nous renvoyer un itérateur mais sur un tableau de u16:

    let drapeau = String::from("🇫🇷");

    println!(
        "{}\nlen: {}\t count_utf-8: {}\tcount_utf-16: {}",
        drapeau,
        drapeau.len(),
        drapeau.chars().count(),
        drapeau.encode_utf16().count()
    );

Résultat:

🇫🇷
len: 8	 count_utf-8: 2	count_utf-16: 4

Bon, ça y est, on vient de tomber sur le bon chiffre, on peut partir du principe que ça suffira. Du coup, pour extraire les 30 premiers «caractères», en UTF-16, à destination dun formulaire, on pourra faire comme ceci:

    let string_30 =
        String::from_utf16_lossy(&string_50.encode_utf16().take(30).collect::<Vec<u16>>());

La fonction from_utf16_lossy permettant de recréer un String Rust à partir de caractères UTF-16 en mettant un caractère invalide à chaque fois quelle ny arrive pas.

Bon ben voilà, cétait pas si compliqué que ça!

/ragequit