191 lines
9.6 KiB
Markdown
191 lines
9.6 KiB
Markdown
+++
|
||
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 d’UTF-16… attendez, je crois qu’on l’a déjà faite celle-là
|
||
|
||
Mon cerveau à deux heures du mat’
|
||
|
||
<!-- more -->
|
||
|
||
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`](https://doc.rust-lang.org/std/string/struct.String.html#method.truncate) de la librairie standard permet de faire ça, ça fonctionne comme suit :
|
||
|
||
```rust
|
||
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 :
|
||
|
||
```bash
|
||
Je suis une lon
|
||
```
|
||
|
||
Sauf que la [doc](https://doc.rust-lang.org/std/string/struct.String.html#method.truncate) 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é :
|
||
|
||
```rust
|
||
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 :
|
||
|
||
```bash
|
||
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`](https://doc.rust-lang.org/std/primitive.str.html#method.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 :
|
||
|
||
```rust
|
||
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 :
|
||
|
||
```bash
|
||
Je suis une trè
|
||
```
|
||
|
||
Par contre, c’est moyennement idiomatique, essayons de faire un peu mieux que ça en utilisant les fonctions [`take`](https://doc.rust-lang.org/std/iter/trait.Iterator.html#method.take) et [`collect`](https://doc.rust-lang.org/std/iter/trait.Iterator.html#method.collect) :
|
||
|
||
```rust
|
||
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…
|
||
|
||
```rust
|
||
let long_string = String::from("Je suis une très longue phrase.");
|
||
|
||
println!(
|
||
"len: {}\ncount: {}",
|
||
long_string.len(),
|
||
long_string.chars().count()
|
||
);
|
||
```
|
||
|
||
Renvoie :
|
||
|
||
```bash
|
||
len: 32
|
||
count: 31
|
||
```
|
||
|
||
Et ça n’a rien de choquant : [`len`](https://doc.rust-lang.org/std/primitive.str.html#method.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`](https://doc.rust-lang.org/std/primitive.str.html#method.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 ?
|
||
|
||
```rust
|
||
let drapeau = String::from("🇫🇷");
|
||
|
||
println!(
|
||
"{}\nlen: {}\t count: {}",
|
||
drapeau,
|
||
drapeau.len(),
|
||
drapeau.chars().count()
|
||
);
|
||
```
|
||
|
||
```bash
|
||
🇫🇷
|
||
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](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/maxlength) 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`](https://doc.rust-lang.org/std/primitive.str.html#method.encode_utf16) qui va elle aussi nous renvoyer un itérateur mais sur un tableau de `u16` :
|
||
|
||
```rust
|
||
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 :
|
||
```bash
|
||
🇫🇷
|
||
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 :
|
||
|
||
```rust
|
||
let string_30 =
|
||
String::from_utf16_lossy(&string_50.encode_utf16().take(30).collect::<Vec<u16>>());
|
||
```
|
||
|
||
La fonction [`from_utf16_lossy`](https://doc.rust-lang.org/std/string/struct.String.html#method.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
|