UTF-8, UTF-16, Rust et le formulaire Web magique
Et là, la marmotte d’UTF-16… attendez, je crois qu’on l’a déjà faite celle-là
Mon cerveau à deux heures du mat’
Je suis tombé récemment sur une bizarrerie tellement étrange qu’il m’a paru intéressant de tenter de la documenter (j’ai 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 l’objectif 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 à l’opposé du name qui est le truc au bout de l’URL 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 t’expliquerais pourquoi plus loin) alors que sur Mastodon, le display name est limité à 30 caractères. C’est comme ça sur l’API et c’est comme ça sur le formulaire permettant de le changer.
Il va donc falloir 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 l’ASCII tout ce qu’il 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
, c’est un peu la merde, on est bien d’accord ?) si par hasard on coupe au milieu d’un caractère. Oui, parce que comme Rust fait de l’UTF-8 partout, tout le temps, certains caractères sont sur plusieurs octets et en fait truncate
ne coupe pas au caractère mais coupe à l’octet… et panique. Essayons de le faire paniquer, tu vas voir que ce n’est 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 lorsqu’il s’agit d’aller chercher des fonctions très chelous permettant de faire des trucs parfois très tordus. Nous allons voir ce qu’il 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 à l’octet. 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 s’arrê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, c’est 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 c’est quand même un poil plus propre à la lecture et surtout, on évite d’avoir un 15
qui se balade dans le code sans qu’on puisse voir directement ce qu’il fait (et si t’aimes pas l’opérateur turbofish, tu peux toujours préciser le type de variable au début, c’est un peu comme tu le sens).
Bon ben ça marche, pourquoi tu nous casses les noix alors ?
Alors, d’abord, 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, l’objectif final, c’est de faire manger cette chaîne de caractères à Mastodon, via l’API, mais en réalité, on peut aussi le voir directement sur l’interface 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 qu’il est limité à 30 caractères, c’est 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, c’est quoi la taille d’une chaîne de caractères ? Et bien, c’est 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 n’a 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), c’est pas forcément le cas pour tous les caractères et ça dépend aussi de l’encodage. 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, c’est « 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. C’est parce que c’est deux caractères sont côte à côte qu’un navigateur va l’afficher comme un drapeau français. Sinon, il affichera simplement l’identifiant correspondant.
longueur_max = toto
Et là, normalement, tu te dis que c’est bon, que tout va bien, que tu vas arriver à quelque chose… mais est-ce que les formulaires Web font de l’UTF-8 ? L’attribut maxlength
représente-t-il vraiment le nombre de caractères ?
Allons faire le test pour vérifier, équipé de notre vaillant 🇫🇷 :
Donc, c’est supposément 30 caractères, on a vu qu’en 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. Qu’est-ce que la baise ?
Et bien, en fait, les formulaires Web encodent en utilisant de l’UTF-16 et non de l’UTF-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 d’un 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 qu’elle n’y arrive pas.
Bon ben voilà, c’était pas si compliqué que ça !
/ragequit