Découper une police web avec unicode-range, sans toucher au design

Photo de profil de Jordan Van Walleghem

Jordan Van Walleghem

08/07/2026

performance

typographie

front-end

10 minutes

Sur le site de L'Exploratoire, le lieu d'orientation professionnelle porté par We Ker à Rennes, la police web qui porte l'identité de marque pesait 241 Ko. Une police manuscrite, Selfie Printed, qui ne rend qu'une trentaine de caractères par page : le « L' » de la barre de navigation, un mot dans un titre de section. Au total, 61 % du budget polices du site pour environ 500 caractères affichés sur l'ensemble des gabarits.

La contrainte de départ était non négociable : ne pas toucher au design. Pas de repli sur une police système, pas de rendu dégradé, pas de compromis sur l'identité typographique. On raconte ici, chiffres à l'appui, comment cette refonte (TanStack Start et Payload 3, en remplacement d'exploratoire.com) a sorti 66 % de ces octets du chemin critique avec une découpe unicode-range, pourquoi deux pistes plus séduisantes ont été mesurées puis écartées, et ce que notre banc de test nous a appris sur les rastériseurs.

Pour rejouer la démarche, il faut peu de choses :

  • fonttools et pyftsubset pour découper les fontes ;
  • un harnais de diff de pixels qui rastérise sur le moteur cible (Chrome headless, pas PIL) ;
  • la liste exhaustive des chaînes rendues dans la police décorative, extraite du code ou du CMS ;
  • la main sur les en-têtes de cache, pour servir les fontes en immutable longue durée ;
  • une CI capable de poser une baseline sur des octets plutôt que sur des scores.

Rien d'exotique. L'essentiel du travail est dans la mesure, pas dans l'outillage.

Le diagnostic : une police web de 241 Ko pour trente caractères par page

Le poids anormal d'une police web se diagnostique en comptant deux choses : les glyphes embarqués, et les points de contour par glyphe. Le diagnostic évident, « elle doit embarquer du cyrillique », était faux : le fichier contient 321 glyphes, tous latins. Rien à purger de ce côté.

La vraie cause est apparue en inspectant les contours : environ 1 000 points par glyphe, contre 20 pour DM Sans, la police de labeur du même site. Le « Printed » du nom est littéral. Chaque lettre trace le bord irrégulier de l'encre posée sur le papier, et ce dessin se paye point par point. Le poids n'est donc pas dans le nombre de glyphes mais dans chacun d'eux, et le sous-ensemblage classique plafonne vite : retirer les 112 glyphes inutilisés n'a fait gagner que 8 %.

Après ce premier passage, la police pesait encore 225 Ko, téléchargés en entier avant que le moindre titre ne s'affiche dans la bonne fonte. Quand chaque glyphe coûte cher, la seule variable qui reste est le nombre de glyphes réellement servis au premier rendu.

Découper la police web en deux faces avec unicode-range

Le descripteur unicode-range autorise plusieurs @font-face sous la même font-family, et le navigateur ne télécharge un fichier que si la page affiche au moins un caractère de son intervalle. C'est le mécanisme qu'utilise Google Fonts pour séparer latin et cyrillique. On l'a appliqué à une seule écriture, découpée non par alphabet mais par usage.

Au premier écran, Selfie Printed ne rend presque rien, et l'ensemble des chaînes statiques du site tient en 34 caractères distincts. D'où deux faces : une face « interface » de 77,6 Ko qui couvre ces 34 caractères, et un complément de 163,2 Ko qui couvre tout le reste et n'est demandé que lorsqu'un glyphe l'exige, donc sous la ligne de flottaison. Le chemin critique passe de 225 à 77,6 Ko, soit 66 % de moins. Et sur 4 gabarits sur 9, aucun caractère hors du jeu critique n'apparaît nulle part : le complément n'est jamais téléchargé, 164 Ko de trafic en moins par visite de ces pages.

/* Face « interface » : les 34 caractères des chaînes statiques.
   Intervalles générés au build, jamais édités à la main. */
@font-face {
  font-family: "Selfie Printed";
  src: url("/fonts/selfie-printed-interface.woff2") format("woff2");
  unicode-range: U+27, U+2019, U+41-5A, U+61-7A, U+E9; /* extrait simplifié */
  font-display: swap;
}

/* Complément : tout le reste de la police, même famille.
   Téléchargé seulement si un glyphe hors du jeu critique s'affiche. */
@font-face {
  font-family: "Selfie Printed";
  src: url("/fonts/selfie-printed-complement.woff2") format("woff2");
  unicode-range: U+20-26, U+28-40, U+5B-60, U+7B-7E, U+A0-2018, U+201A-FFFF;
  font-display: swap;
}
flowchart TB
    subgraph build["Au build"]
        A["Props script= des composants de titre"] --> B["Script d'extraction"]
        B --> C["34 caractères distincts"]
        C --> D["pyftsubset : face interface, 77,6 Ko"]
        C --> E["pyftsubset : complément, 163,2 Ko"]
        C --> F["unicode-range générés dans le CSS"]
    end
    subgraph runtime["Au rendu"]
        G["Caractère affiché en Selfie Printed"] --> H{"Dans le jeu critique ?"}
        H -->|oui| I["Seule la face interface est téléchargée"]
        H -->|non| J["Le complément part à ce moment-là"]
    end
    D -.-> I
    E -.-> J

Un jeu critique extrait du code, jamais maintenu à la main

Les 34 caractères ne vivent pas dans un fichier qu'un développeur penserait à mettre à jour. Un script de build lit les props script= des composants de titre, en déduit le jeu de caractères, puis génère les deux sous-ensembles et les unicode-range correspondants. Un nouveau titre statique régénère tout ; il ne peut pas périmer la découpe en silence.

Pourquoi un mot à cheval sur les deux faces ne bouge pas d'un pixel

Les deux faces sont deux sous-ensembles de la même police. Un mot dont certaines lettres viennent du premier fichier et d'autres du second se rend exactement comme avant la découpe : mêmes contours, même métrique, même crénage à l'affichage. C'est toute la différence avec un repli sur police système, qui changerait d'écriture au milieu d'un mot.

On l'a vérifié plutôt que supposé : diff de pixels dans Chrome, gabarit par gabarit, entre le site avant et après découpe. Zéro écart. La découpe optimise le transport des octets ; le rendu, lui, ne change pas.

Deux pistes mesurées puis écartées

Les pistes écartées méritent d'être racontées : sur le papier, les deux étaient plus séduisantes que la découpe.

Convertir les contours CFF en TrueType : dominée sur les deux axes

Le format woff2 n'applique sa transformation la plus efficace, celle de la table glyf, qu'aux contours quadratiques TrueType ; les contours cubiques CFF passent au compresseur Brotli sans prétraitement. Selfie Printed est une police CFF. La convertir en TrueType ouvrait droit à cette transformation, et la mesure donnait 197 Ko, soit 18 % de gain.

Le piège : la conversion cubique vers quadratique est une approximation, et son erreur se paye en pixels. Pour retrouver le niveau de fidélité du témoin exact, il fallait pousser la précision de conversion jusqu'à un fichier de 231 Ko, plus lourd que le simple sous-ensemble, qui lui ne déplace rien. Plus lourde à fidélité égale, moins fidèle à poids égal : dominée sur les deux axes, écartée.

Vectoriser le premier écran en SVG : les mauvais octets au mauvais endroit

Deuxième idée : remplacer les quelques glyphes du premier écran par des tracés SVG inline et différer toute la police. Mesuré : 8,7 Ko gzip pour le « L' » seul, jusqu'à 39,3 Ko avec un titre plus long. Moins que 77,6 Ko, donc gagnant ?

Non, à cause de la nature de ces octets. Ils gonflent le document HTML, la seule ressource réellement bloquante du rendu, ils ne sont ni mutualisés entre les pages ni mis en cache, et ils se repayent à chaque navigation. Les octets de fonte qu'ils prétendent remplacer sont exactement inverses : non bloquants grâce à font-display: swap, partagés entre tous les gabarits, et cachés un an.

PisteChemin critiqueFidélité de renduNature des octetsCache et mutualisationVerdict
Sous-ensemble seul225 Koexactewoff2, non bloquant1 an, partagéplafonne, le poids est par glyphe
Conversion CFF vers TrueType197 Ko, ou 231 Ko à fidélité égaleapproximativewoff2, non bloquant1 an, partagédominée sur les deux axes
Vectorisation SVG du premier écran8,7 à 39,3 Ko ajoutés au HTMLexacteHTML, bloquantaucun, repayé à chaque pageécartée
Découpe unicode-range77,6 Koexacte, même policewoff2, non bloquant1 an, partagéretenue

Le piège qu'on a vraiment vécu : le banc de test rastérisait à côté

Symptôme : notre premier banc de fidélité, monté avec PIL et FreeType, déclarait la conversion TrueType gratuite. Aucune perte visuelle mesurable, 18 % de gain, dossier réglé.

Diagnostic : FreeType ne rastérise pas les deux formats avec le même moteur. Les contours CFF passent par l'algorithme d'Adobe, les contours TrueType par un autre interpréteur. Entre deux rendus d'une même police dans les deux formats, il existe donc un plancher de bruit d'environ 3 % qui n'a rien à voir avec la conversion, et ce plancher noyait entièrement l'erreur qu'on cherchait à mesurer. On comparait deux rastériseurs, pas deux polices.

Fix : rejouer la même matrice de comparaisons sur Skia, le moteur de rendu de Chrome, celui qui affiche réellement le site aux visiteurs. Le plancher disparaît, l'erreur de conversion devient nette et mesurable, et la piste TrueType tombe pour de bon. Sans ce second passage, on aurait mis en production une dégradation visuelle en croyant à une optimisation gratuite.

Dans la même campagne : OpenDyslexic, fetchpriority et le LCP

Le widget d'accessibilité du site servait ses quatre polices OpenDyslexic en OTF brut, non compressé : 234,5 Ko. Reconditionnées en woff2, elles tombent à 151,2 Ko, à fidélité exacte par construction : woff2 est un conteneur de compression qui restitue les tables de la police sans en réinterpréter les contours. Ce gain ne touche que les visiteurs qui activent le mode dyslexie, précisément ceux à qui on doit le moins d'attente. C'est la même exigence qu'on applique sur Tandem Mobilités, et la raison pour laquelle nos audits RGAA regardent aussi la performance des parcours adaptés.

L'audit a aussi remis le LCP (Largest Contentful Paint) au centre. Sur 9 gabarits, 6 rendaient leur élément LCP sans fetchpriority, dont trois en loading="lazy" : on demandait au navigateur de déprioriser exactement l'élément qu'on mesure. L'accueil, seule page correctement configurée, tenait un LCP de 2,9 s contre 3,8 à 4,3 s ailleurs. Subtilité qui a son importance : la moitié de ces pages porte son LCP dans un background-image CSS, qui ne peut recevoir ni loading ni fetchpriority. Pour ces cas, la correction n'est pas la même, il faut un preload explicite avec fetchpriority="high".

Restait un détail à 23 Ko : notre commande de sous-ensemblage passait --layout-features='*' et conservait des fonctionnalités OpenType que ce texte ne déclenche jamais. Rendu vérifié pixel pour pixel identique sans elles.

# Jeu critique extrait des props script= au build
pyftsubset selfie-printed.otf \
  --text-file=dist/critical-chars.txt \
  --flavor=woff2 \
  --output-file=public/fonts/selfie-printed-interface.woff2
# Surtout pas --layout-features='*' : 23 Ko de tables OpenType
# que ce texte ne déclenche jamais, rendu vérifié identique sans.

Lighthouse ne peut pas être le garde-fou : mesurer la médiane et la dispersion

Sur cette production, cinq passes Lighthouse donnent un écart de LCP allant de 0 à 101 % selon la page, et des scores de 74 à 100 pour un contenu strictement identique. Poser un seuil bloquant sur ces chiffres, c'est fabriquer des faux positifs, ou pire, de la confiance infondée quand la passe tombe du bon côté.

La CI mise en place mesure donc en médiane et en dispersion, et ne pose de baseline que sur ce qui est déterministe : octets transférés par type de ressource, liste des fontes réellement téléchargées gabarit par gabarit, présence de fetchpriority sur l'élément LCP, absence de loading="lazy" sur ce même élément. Une régression sur ces invariants casse le build ; personne ne peut réintroduire les 148 Ko sans le voir. Les métriques temporelles et les scores, eux, se surveillent en tendance sur plusieurs runs, jamais en garde-fou unitaire.

C'est notre position sur les Core Web Vitals en général : distinguer ce qui se mesure de ce qui se surveille. Un octet est un fait, un score Lighthouse est une estimation contextuelle. On construit les alarmes sur les faits.

Questions fréquentes

Q: Comment réduire le poids d'une police web sans changer de design ?

A: Dans l'ordre : servir du woff2, sous-ensembler aux glyphes réellement utilisés avec pyftsubset, retirer les fonctionnalités OpenType jamais déclenchées, puis découper la famille en plusieurs faces unicode-range si le poids par glyphe reste élevé. Chaque étape se valide par un diff de pixels sur le moteur du navigateur cible, ce qui garantit un rendu strictement identique.

Q: À quoi sert unicode-range dans @font-face ?

A: unicode-range déclare l'intervalle de points de code couverts par un fichier de police. Le navigateur ne télécharge ce fichier que si la page affiche au moins un caractère de l'intervalle. Plusieurs faces déclarées sous la même famille permettent de ne charger au premier rendu que les caractères qui y apparaissent.

Q: Pourquoi une police manuscrite pèse-t-elle plus lourd qu'une police classique ?

A: Ses contours sont plus complexes. Sur le site de L'Exploratoire, chaque glyphe de la police manuscrite Selfie Printed compte environ 1 000 points de contour, contre 20 pour DM Sans : le dessin du bord irrégulier de l'encre se paye octet par octet, glyphe par glyphe. Le sous-ensemblage seul ne suffit alors plus, le poids est dans chaque lettre.

Q: Le woff2 suffit-il à optimiser une police web ?

A: Le woff2 est le bon format par défaut, compression Brotli et support universel, mais il ne remplace ni le sous-ensemblage ni la découpe unicode-range. Sur une police à contours CFF, sa transformation la plus efficace ne s'applique même pas : le format ne peut pas compenser un dessin de glyphes intrinsèquement lourd.

Q: font-display: swap protège-t-il le LCP ?

A: Il empêche la police de bloquer l'affichage du texte, rien de plus. Si l'élément LCP est une image en loading="lazy", ou un background-image CSS sans preload, le LCP reste dégradé quelle que soit la stratégie de fontes. Les deux chantiers sont indépendants et se mesurent séparément.

Ce qu'on retient

La contrainte « ne pas toucher au design » n'a jamais été l'ennemie de la performance sur ce projet. Elle a forcé les bonnes questions : où vivent les octets, qui les paye, à quel moment, et sur quel moteur se vérifie le rendu. Une police web s'optimise comme n'importe quelle ressource critique, en mesurant chaque piste, y compris celles qu'on finit par écarter, et en refusant les gains qui déplacent le coût au mauvais endroit.

L'agence Platane (https://platane.io) a mené cette campagne dans le cadre de la refonte du site de L'Exploratoire, en TanStack Start et Payload 3, avec la même discipline de mesure qu'on applique au reste de notre travail d'ingénierie. Les chiffres de cet article sortent de cette production, pas d'un banc d'essai de laboratoire.

Le BlogDes infos, des actus, du fun !

04/04/2026

SEO multilingue : pourquoi on est passé du blog FR/EN au FR-only

Notre blog FR/EN nous a coûté 30 % de trafic organique en quelques semaines. Retour d'expérience SEO et décision de revenir au FR-only.
lire l'article

26/04/2026

Postgres pg_hint_plan : forcer GIN vs GiST trigram en prod RAG

En production, un mot a fait scanner notre Postgres 38 minutes. Comment pg_hint_plan a remplacé l'espoir par un BitmapScan déterministe sur GIN trigram.
lire l'article
Image de couverture de l'article de blog

30/07/2026

CRM pour courtier en assurance : quand le sur-mesure et l’IA deviennent utiles

CRM standard, logiciel de courtage ou sur-mesure ? Comparez les options et voyez comment l’IA traite les dossiers sans retirer la validation au courtier.
lire l'article

Nous contacterOui allo ?

Nous envoyer un message

facultatif

Prendre rendez-vous

Vous préférez discuter de vive voix ? Nous aussi et c'est évidemment sans engagement !

Nous appeler

Une question, un besoin de renseignements ? N'hésitez pas à nous contacter.

Logo Activateur France Num

Activateur France Num

Platane a rejoint l'initiative France Num pour accompagner les TPE PME dans leur transformation numérique : diagnostics, formations et aides financières.

Pourquoi faire appel à un expert du numérique référencé par France Num ?
logo de Platane.io
2 b rue Poullain Duparc - 35000, Rennes
69 rue des Tourterelles - 86000, Saint-Benoit
+33 7 70 48 29 48
Retrouvez-nous sur
AWS Certified
Scaleway CertifiedCertifié(e) Access42Certifié(e) Opquast

Expertise qualité web certifiée pour des sites performants et accessibles

Agréé Crédit Impôt Innovation

Agréé Crédit Impôt Innovation