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

191 lines
9.6 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

+++
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
<!-- more -->
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 quil va devoir couper une chaîne de caractères](/2022/12/736lvk.jpg)
# 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`](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`, 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é:
```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 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`](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 à 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:
```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, cest 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 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…
```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 na 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), 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?
```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, 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!](/2022/12/30_char_mon_cul.png)
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](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 dun 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 quelle ny arrive pas.
# Bon ben voilà, cétait pas si compliqué que ça!
/ragequit