Recréer une extension qui aide à lire plus vite avec CSS3

6 février 2022

Tout le code est disponible sur mon gitlab : florian-s/gradient-line-reader

Spoiler : pour voir ce que ça donne avant même de lire l’article, activez le dégradé ci-dessous.

Il y a quelques jours, je suis tombé sur le site de “Beeline Reader”. Le concept est assez simple : changer la couleur du texte d’un article ou d’un livre pour faciliter et accélérer la lecture. Les recherches qu’ils ont effectuées ont l’air assez sérieuses, mais elles n’ont pas encore été reproduites. En tout cas, j’ai plutôt bien aimé.

Concrètement, ça ressemble à cela :

Exemple de l'effet dégradé de Beeline Reader, issu du site officiel.
Exemple de l'effet dégradé de Beeline Reader, issu du site officiel.

Quand on regarde le code, on voit que c’est fait comme ça :

    <beelinespan class="beeline-node" style="color: rgb(0, 0, 1) !important;">d</beelinespan>
    <beelinespan class="beeline-node" style="color: rgb(0, 0, 1) !important;">i</beelinespan>
    <beelinespan class="beeline-node" style="color: rgb(0, 0, 2) !important;">f</beelinespan>
    <beelinespan class="beeline-node" style="color: rgb(0, 0, 3) !important;">f</beelinespan>
    <beelinespan class="beeline-node" style="color: rgb(0, 0, 4) !important;">e</beelinespan>
    <beelinespan class="beeline-node" style="color: rgb(0, 0, 5) !important;">r</beelinespan>

Pour récréer un effet de dégradé, chaque lettre est entourée d’un élément <beelinespan>, fonctionnellement équivalent à un <span>, avec une couleur différente variant du noir au rouge. En voyant cette abondance de balises, je me suis demandé s’il était possible de faire la même chose uniquement en CSS.

J’ai donc passé quelques heures ce week-end à coder un petit script qui colore un texte en dégradé.

Le résultat final est quand même satisfaisant : un user-script compatible avec les extensions comme GreaseMonkey qui remplace assez bien une véritable extension comme celle que propose Beeline Reader.

Comme j’ai passé du temps à coder ça, je peux vous le proposer pour cet article. Cliquer sur le bouton ci-dessous pour l’activer.

J’avais la possibilité de choisir les couleurs donc j’ai choisi des couleurs un peu plus jolies que du bleu et rouge.

Après avoir fait ce projet, je peux comprendre le choix qui a été fait par l'entreprise. Leur méthode est plus simple à mettre en oeuvre et sans doute plus fiable : elle ne dépend pas du CSS existant sur la page et laisse le travail au navigateur. Mais ce n'est pas aussi satisfaisant que d'utiliser du CSS !

Recherches et réalisation

Première fonctionnalité nécessaire : les dégradés CSS. On obtient très facilement cela :

background: linear-gradient( ... );
Exemple de fond dégradé.

Ensuite, il ne manque plus qu’une propriété CSS, un peu moins commune, nommée background-clip. Complétée par un color: transparent qui rend notre texte transparent, cela donne ça :

background: linear-gradient( ... );background-clip: text;color: transparent;
Exemple de texte avec effet dégradé.

Malheureusement, jusque-là ça ne reproduit pas tout à fait l’effet que l’on souhaite : le sens du dégradé et les couleurs sont normalement alternés une ligne sur deux.

Mais c’était sans compter sur la puissance de la propriété background de CSS :

  1. Il est possible de spécifier la taille du fond (background-size) pour que notre dégradé couvre une seule ligne.
  2. Il est possible de spécifier plusieurs background pour notre élément et donc de définir un dégradé différent pour chaque ligne d’un même paragraphe.
  3. Il est possible de spécifier la position du fond (background-position) pour pouvoir positionner le dégradé derrière chaque ligne.

En pratique, pour un paragraphe d’une seule ligne cela donnerait ça :

  p {
    background-repeat: no-repeat;
    background-image: linear-gradient(90deg, #000000, #ff0000);
    background-size: 100% 22.5px;
    background-clip: text;
    color: transparent;
  }

Il faut connaître la hauteur de la ligne (autrement dit line-height, ici 22.5px) pour définir la taille du dégradé. Pour colorer une deuxième ligne, on ajoute un second background en changeant le sens du dégradé et en changeant sa position :

  p {
    background-image: linear-gradient(90deg, #000000, #ff0000), linear-gradient(90deg, #ff0000, #000000);
    background-size: 100% 22.5px, 100% 22.5px;
    background-position: left top 0px, left top 22.5px;
  }

On voit vite que l’on est limité par le CSS si l’on veut utiliser cette méthode pour des paragraphes quelconques : les valeurs à utiliser dans le CSS dépendent de notre paragraphe. Or, tous les paragraphes d’une page n’ont pas le même nombre ni la même hauteur de ligne.

C’est ici qu’intervient le javascript : voilà une fonction qui génère notre dégradé en fonction des paramètres de notre paragraphe. Il y a trois choses à noter :

  1. Il faut prendre en compte les marges padding pour que notre dégradé couvre bien notre texte.
  2. J’utilise ici la propriété composée background qui rassemble toutes les propriétés présentées ci-dessus pour avoir une seule propriété à changer par paragraphe.
  3. J’ai créé une classe CSSDim pour ce script, j’en parle juste après. Il nous permet de garder ensemble la valeur d’une propriété CSS et son unité (px, em, rem…)
    /**
     * @param {list} colors List of colors to use
     * @param {CSSDim} lineHeight The line height of the paragraph
     * @param {Number} nbOfLines Line number in the paragraph
     * @param {CSSDim} paddingTop The top padding
     **/
    function getGradientBg(colors, lineHeight, nbOfLines, paddingTop) {
    
      let bgString = "";
      for(i = 0; i < nbOfLines; i++) {
        
        let topPos;
        if(paddingTop.value != 0) {
        	topPos = `calc(${i*lineHeight.value}${lineHeight.unit} + ${paddingTop.value}${paddingTop.unit})`; //we use css calc to deal with different units
        } else {
          topPos = `${i*lineHeight.value}${lineHeight.unit}`;
        }
        
        bgString += `linear-gradient(90deg, ${colors[i%colors.length]}, ${colors[(i+1)%colors.length]}) top ${topPos} left/100% ${lineHeight.value}${lineHeight.unit} no-repeat`;
        if(i < nbOfLines - 1) bgString += ", ";
      }
      return bgString;
    }

Parse, don’t validate

Pour compléter ce script, il faut calculer les différents arguments pour la fonction ci-dessus puis l’appliquer à tous les paragraphes de la page. Il y a quelques spécificités à connaître (notamment comment fonctionne la propriété line-height) mais rien de compliqué. Je vous invite donc à aller lire ça dans le code source (que j’ai commenté pour faciliter la lecture !).

Au lieu de cela, j’aimerais finir sur la conception de cette classe CSSDim et la philosophie qui est derrière. C’est l’article d’Alexis King “Parse, don’t validate” qui m’a inspiré pour cette classe. C’est aussi l’article technique qui m’a le plus marqué jusqu’à aujourd’hui. Je vous conseille bien sûr de le lire, mais je vais essayer d’expliquer le principe avec mon exemple.

Une fois que l’on a réussi à s’habituer à la syntaxe du Haskell utilisé pour illustrer l’article, on comprend vite l’intérêt de créer des types (en JS on se limitera à des classes, GreaseMonkey ne supporte pas le Typescript) pour les données que l’on doit « parser ».

Notre fonction getGradientBg dépend ici des propriétés line-height et padding-top que l’on doit récupérer depuis le CSS. Comme les valeurs sont sous forme de texte (CSSOMString) on doit justement les parser pour identifier la valeur numérique et son unité. Mais il y a deux cas particuliers : certaines propriétés n’ont pas d’unité et certaines valeurs peuvent être remplacées par des mots clés spéciaux (par exemple inherit ou normal pour line-height).

Ma classe CSSDim (ci-dessous) représente toujours un valeur numérique avec une unité. En m’assurant que les arguments lineHeight et paddingTop sont des instances de CSSDim, je n’ai pas besoin de m’occuper des cas particuliers dans ma fonction getGradientBg (note : en l’absence de la vérification des types de Typescript, il n’y a pas de vérification formelle mais la fonction n’acceptera pas un simple Nombre à la place d’une instance de classe.)

    class CSSDim {
      
      /**
       * @param {Number} value The value of the property can be not numeric
       * @param {String} unit The unit of this CSS value
       **/
      constructor(value, unit) {
        if(isNaN(value) || !unit) throw new TypeError("Wrong types for value and unit of this CSSDIM");
        
        this.value = value;
        this.unit = unit;
      }

      /*
      ...
      */
    
      /**
       * Parse a CSSOM String
       * @param {String} dimString A CSSOM String extracted from style informations
       * @return {Object} A result object containing a success boolean and either value or error values
       *       | success {boolean} is True if the parse was successful, False otherwise
       *       | value {CSSDim) is set when success is true, represents the parsed CSSDim
       *       | error {String} is set when success is false, gives information on the erreor
       *
       * Note : in this script, we do not check everything, we trust the value to parse to not be complete garbage
       **/
      static parse(dimString) {
        let unit = CSSDim.getUnit(dimString);
        let parsedValue = parseFloat(dimString);

        if(isNaN(parsedValue) || unit === null) {
          return {success: false, error: "This CSS string is not a CSS dimension"};
        }

        return {success: true, value: new CSSDim(parsedValue, unit)};
        return {success: true, value: new CSSDim(parsedValue, unit)};
      }
    }

Ces vérifications sont effectuées lorsque je « parse » ma valeur avec la méthode CSSDim.parse(). Quand cette méthode réussit, je suis sûr que je peux faire confiance à mon instance de CSSDim que me renvoie cette méthode. Quand ça ne parse pas correctement (pour les valeurs sans unité), je gère ces exceptions directement au moment du parsing et je n’ai plus à m’en soucier après.

    let pStyle = window.getComputedStyle(p);
    
    //find padding, if not found the default value is 0px
    let paddingTop_parse = CSSDim.parse( pStyle.getPropertyValue('padding-top') );
    let paddingTop = paddingTop_parse.success ?
          		 paddingTop_parse.value :
      		 new CSSDim(0, "px"); //default value

L’objet intermédiaire retourné par CSSDim.parse() contient une propriété success qui nous permet de vérifier si la valeur a pu être parsée, si c’est le cas nous pouvons obtenir l’instance CSSDim créée avec la propriété value.

Pour un petit projet ce n’était pas 100 % nécessaire, mais ça a quand même permis de rendre le code plus propre.

Code sur gitlab : florian-s/gradient-line-reader

Extension GreaseMonkey :via addons.mozilla.org

À propos de l'auteur

Florian Susini

Je suis un passionné de développement web, de logiciels open-source et de tout ce qui touche au numérique. Je partage de temps en temps des projets web ou des idées sur ce blog.

Retour à l'accueil