Radiation de pixels, quand la 2d est 3D et vice versa

Suite des articles visant à améliorer l’illumination de la scène, après une couleur diffuse correctement éclairée grâce aux normales et aux spéculaires, il restait, invariablement, que l’image entière était éclairée uniformément concernant l’intensité, pour une même direction de normales.

Mise à plat de la scène pour valider le dégradé de l’intensité.

Les calculs et les informations transmises étaient insuffisantes pour calculer la valeur de chaque pixel et, surtout, rien ne nous permet de savoir où le pixel se situe dans l’espace, nous permettant de faire varier son illumination sur base d’une distance altérée par sa position.

Je ne suis pas 3D

N’oubliez pas que ce que vous voyez n’est pas de la 3D, mais une succession d’images plates représentant « un quelque chose » en 3D, et c’est cet enchevêtrement savamment orchestré qui vous dit que l’image est 3D. Tout n’est qu’illusion Mr. Anderson ! Et votre cerveau vous trompe, et c’est le but !

Je l’ai déjà dit dans les précédents articles, mais c’est là tout le problème, comment donner une information 3D à une image plate ? Certes nous avons les normales pour nous donner la direction, mais qu’en est-il de la position ? Comment puis-je savoir que le haut de mon bloc est une surface au Z équivalant, variant sur X et Y ?

Explication du process depuis le début.

Reprenons depuis le Zébut

Un Tile est l’image représentant une zone, une unité de notre grille. Celle-ci a ses coordonnées X et Y. La distance entre leur position sera de 1 (1x, 1y ou 1x1y).

L’éclairage d’un Tile est soumis à la propagation de la lumière dans le subset de la map courante (cela nous rappelle de vieux souvenir [2018 !]). La propagation reste importante pour savoir quel Tile est impliqué dans l’éclairage (optimisation).

Un subset reprend une partie des éléments du monde concernés par le rendu à l’écran.

On parlera donc de la distance (parcourue ici) entre le Tile que l’on veut éclairer par rapport à la source lumineuse et sa force.

Une lumière peut donc éclairer sur une distance (d) un objet proportionnellement à sa force (S) à cette distance et selon l’intensité (i) de cette lumière. Nous obtenons alors le facteur (f) d’éclairage du pixel (rgb aux coordonnées uv).

f = (1 - (d * (1 / S))) * i

On obtient une valeur (float) allant de 0 à 1, qui sera notre facteur d’éclairage du Tile.

Ça c’était avant

Ce qu’il nous manquait jusque là c’est d’avoir toutes les coordonnées au niveau du shader pour effectuer ce calcul de distance au niveau du pixel.

On calculera ainsi notre distance autrement, c’est-a-dire sur base des coordonnées du Tile (t) et de la lumière (l), sans oublie l’intensité (i).

d = √((l.x - t.x)² + (l.y - t.y)²) * i

Donc on propage, on définit pour chaque Tile concernés les lumières impliquées, on ajoute les informations manquantes jusqu’alors et … ? Ben jusque là rien de nouveau sauf une distance plus correspondante à l’origine mais pas par rapport aux obstacles rencontrés entre la source et l’élément éclairé, car ça, je suis sur que vous les aviez oubliés.

On ne traverse pas un mur, la lumière « contourne » par rebond etc. Donc la propagation reste la bonne manière mais ne donnera pas le bon résultat en isométrique, et j’espère que vous comprenez pourquoi avec toute cette suite d’articles ahah. En bref, on a pas la possibilité de savoir comment la lumière est arrivée à chaque pixel, il faudrait calculer pour chaque objet un pixel de rebond (surement sa normale), balayer ainsi dans tous les sens et à chaque contact : éclairer ce pixel et modifier le rayon pour le faire repartir (couleur, angle, puissance, …), bref on réinventerai un rendu par projection de rayons en 2D sur base d’information 3D stockée dans les textures. Non !

Spatiale 3D dans de la 2D

Ce qu’il nous manque à cette étape est le moyen de faire évoluer notre formule de calcul de la distance pour inclure la position du pixel rendu de notre texture.

Nous avions vu plus haut que le Tile se définit par une position X et Y, ici en sont centre, sur la partie supérieur pour représenter le sol. Nous ne parlerons pas de Z ici mais l’idée sera la même une fois que j’aurais réussi à ramener l’information jusqu’au shader.

Si un Tile est à une distance de 1 de ses voisins alors il a à ses extrémités un delta variable de [-0.5, 0.5] en X et en Y. Z étant, lui, soumis à un facteur d’élévation définit par la scène, ici de 8 pixels, et donc aura une idée différente de sa distance, du moins c’est ainsi que l’élévation avait été définie au tout début.

Nous avions jusque là 3 textures : la diffuse (l’image elle-même), la normale (direction d’éclairage) et le spéculaire (carte de où appliquer le spéculaire). Et bien nous allons en ajouter une nouvelle.

Suivant la même idée que pour la texture de normales, nous réutiliserons notre définition RGB de nos repères : X en rouge, Y en vert et Z en bleu. Pour chaque pixel de l’image nous définirons ainsi sa position relative par une valeur RGB. Ne pas oublier que le centre ne sera pas 0, car nous avons besoin des deltas négatif, ainsi nous commencerons au centre à 128,128,128.

Un programme, un plugin, ou une théorie pour le faire de manière automatique n’existant pas, on va devoir y mettre de l’huile de coude.

Ainsi comme vous pouvez le voir, si on veut s’aider, nous pourrons compter sur les gradient, un bon dégradé bien réfléchi qui sera combiné à une couche supplémentaire. Nous commencerons par le XY et on surcouchera par le Z. Un total de 2h nécessaire avant d’arriver au résultat que voici.

Notre formule de distance peut donc être complétée par cette nouvelle information (pp).

d = √((l.x - (t.x + pp.x))² + (l.y - (t.y + pp.y))²) * i

Ce qui nous donnera sans plus attendre ce premier résultat :

Wow que se passe-t-il ? On a notre dégradé * fête * cependant nous avons un drôle d’aspect sur certain Tile. Notre algo utilise la force (S) pour se propager, mais comme vous l’avez vu, la propagation est plus courte que le segment calculable en ligne droite donc nous arrivons trop court, nous n’avons pas assez s’électionné de Tiles pour notre impact. L’idée serait surement de refaire un calcul sur base de la distance au lieu de calculer le nombre de saut, mais pour le workaround j’ai ajouté +2 à la force (S).

On en profite pour restaurer le calcul du spéculaire maintenant que nous avons de vraies informations et ce foutu éclair blanc au coffre s’en va !

Il y a bien évidemment des effets de bords, comme le fait que la distance parcourue n’étant plus impliquée, si vous fermez la porte, le Tile de l’autre côté sera éclairé comme si vous étiez à côté au lieu d’un faible éclairage (la lumière fait le tour du mur). Les tables clignotent et là je ne sais pas encore pourquoi. C’est plus joli mais moins cohérent. On en revient à l’idée des rebonds, du vecteur qui doit bouger selon l’idée mais rendre chaque Tile cohérent entre eux, là je ne vois pas comment.

J’ai quand même regardé comment ajouter la distance à la formule mais le résultat empirique n’est pas terrible.

Ajouter la distance assombrit la scène

Et en fait si on déplace la lumière selon la distance parcourue dans la même direction, on éloigne alors virtuellement notre Tile voisin. Et ainsi le résultat tiens compte de la distance. Ceci dit l’éclairage en a pris un coup, il y a surement quelque chose à faire avec ça.

lightPos += lightDirection * lights[i].distance;

Pour bien faire il faudra savoir si la distance correspond à un voisin, et ainsi différencier si notre voisin a une distance de 4 ou de 1 et appliquer l’éloignement que dans ce cas. Reste à trouver comment faire.

Et ensuite ?

Comme toujours, quelle est l’étape suivante et comment adresser les nouveaux problèmes, ou ceux mis de côté ? Plus j’avance dans TARS et plus les limites, personnelles et techniques se montrent. Il est grisant de se dire que j’ai relevé un défi qui, selon Google, n’a pas été adressé ou n’est actuellement plus référencé. Il faut aussi se rendre compte de l’effort considérable que cela demanderait si l’on voulait faire un jeu entier ainsi. Et là encore nous n’avons exploré que la lumière (par rapport à l’existant réalisé).

C’est le cœur serré que je me dis que j’ai clôturé cette étape, et qu’il faut, car le temps n’est pas infini, et que je n’ai pas que ce projet à faire, passer à autre chose… Quand je dis « passer à autre chose« , ce n’est certainement pas abandonner Nahyan, certes non ! Mais explorer (enfin) une autre piste que celle de tout faire moi-même, même si je n’ai pas fort le choix. Je ne vais pas en dire plus pour le moment, j’ai déjà commencé à explorer d’autres possibilités et vous raconterais le moment venu.

Cela fait 7 ans que TARS existe, avec une belle pause au milieu, et 2-3 belles phases de développement, comme le passage au WebGL pour adresser les performances sur l’éclairage première version, ou encore la refonte des classes communes pour mieux gérer les données et factoriser le code générique. Et enfin ici, le nouveau système d’éclairage, mais in fine rien de plus, nous n’avons toujours pas vu « Le temple du Prisme« , un HéroQuest ou un Liebesstein.

Qui sait que j’y toucherai par défi encore 😉

Je spécule un scapulaire

J’ai décidé de me pencher sur la question du spéculaire, c’est à dire ajouter un éclat de lumière à certains pixels. Quand on regarde ce que l’on a déjà fait et ce que l’on a trouvé sur le net cela semble facile, du coup, challenge !

Une fois que l’on a calculé la valeur de notre pixel, on a la valeur diffuse, ce qui sera la couleur affichée. Sur base des données que l’on a déjà, comme la lumière (position, vecteur), la normale de notre pixel et l’atténuation (facteur de notre lumière), alors il nous suffit d’une petite formule.

On crée un vecteur (vec3) représentant le point de vue (le nôtre), on le combine au vecteur de la lumière, on normalise, on fait le produit scalaire avec la normale du pixel, on restreint la valeur dans l’intervalle [0, 1] et on l’augmente par un exposant (32, qui peut être de 2 à n suivant l’effet voulu), enfin on l’ajoute à la couleur de manière équivalente aux 3 couleurs RGB pour le tendre vers le blanc, mais on pourrait appliquer un modificateur suivant cette documentation d’Unreal Engine.

Le résultat est blanc ! Sans dégradé ni distinguo, juste blanc ! Là commence une bataille en heures sur des variantes de la formule ou des valeurs des vecteurs. Je vous épargne, c’était « impossible », dans le sens où encore une fois, notre contexte n’est pas le contexte classique 3D et que mes notions de super matheux sont fictives.

Ce blanc est dû à un vecteur, celui du point de vue, et selon ce que l’on met, c’est tout noir ou tout blanc, et comme j’ai chipoté longtemps sans versionner les résultats, je ne peux que vous le raconter. Ensuite j’ai recomposé la couleur diffuse et isolé le spéculaire. Notre ennemi c’est Z, il est imposé à 1 et nous ne gérons pas la hauteur relative actuellement, donc tout est à 1 mais du coup seule la face de « sol » (les normales 0,0,1 soit bleues) sont violemment surchargées.

Démonstration de l’effet spéculaire incorrect pour les normales verticales 0,0,1/bleue

Comme pour la couleur diffuse il nous faudra manipuler le concept. J’en ai été à lire au sujet des produits scalaire et normalisation de vecteur et refaire les calculs à la main, ce qui a aidé, un peu quand même. Ce que j’ai obtenu est un nombre toujours en dessous de 1 (et > 0) ce qui fait qu’élever un 0,x à la puissance 32, le réduira toujours plus (0,0…00x).

La solution, remultiplier ce résultat par 255 pour le renvoyer dans l’espace de valeurs RGB. C’est empirique, mais le résultat donne quelque chose d’intéressant, donc pour une fois qu’on approche d’un truc passable…

Spéculaire sur le coffre, sur sa partie « métallique »

J’en ai profité pour faire quelques textures de normales, ce qui prends, à la main et par pixel, un temps fou. Et vu qu’on fait des modifications de shader et du système de rendu, pourquoi ne pas faire un truc qui n’était pas dans la liste et ajouter une texture de spéculaire, c’est-à-dire une image représentant les zones où appliquer l’effet spéculaire à notre image. Par exemple sur le coffre, ne prendre que les parties métallique.

Ainsi j’ai créé cette image, en utilisant la couche rouge qui peut varier de 0 à 255, ce qui se ramène à une valeur [0,1] permettant de multiplier le résultat spéculaire calculé, tel un facteur spécifique au pixel.

Ainsi, en une seule texture additionnelle et en utilisant les couches RGB de l’image, ainsi que l’alpha (transparence), nous pouvons stocker jusqu’à 4 valeurs permettant d’ajouter des modificateurs visuels à notre texture de base, tel que l’information de hauteur qui nous manque, le spéculaire dont on a parlé, peut-être une information d’ombrage, plutôt que de l’avoir dans l’image rendue, telle qu’actuellement. C’est à voir et investiguer mais ça reste intéressant.

Évidemment charger une énième texture méritait une factorisation côté sprite. On ajoute un type de texture (diffuse, normale, speculaire) en enum et on rend tout le monde meilleur. C’est encore fort « recherche et développement », pas très fini et très améliorable, tout est discutable, mais le résultat apparait 🙂 et ça, c’est bien !

Dernière amélioration, qui se voit sur les murs, ceux en position Est ou Sud, c’est à dire ceux que l’on voit mais qui n’appartiennent pas à notre bloc courant, mais également ceux en Nord et Ouest, mais cette fois sur le bloc courant, ces 4 cas, ne recevaient pas la lumière adéquatement.

Dans le cas du mur à gauche de la porte au début de la vidéo (donc un mur Est, sur le bloc x:1,y:3) on marche à côté sur x:2,y(2,3,4). Le mur était noir car la source de lumière partait de x:2,y3 (par exemple si on est à droite du mur avant la porte), le chemin de la lumière passait à x2:y4, x1:y:4, x1:y:3 et comme c’est un cas Est (ou Sud), ceux-ci ne prennent pas la lumière car « ils tournent le dos » à la case. Sauf que si je suis à côté du mur, d’une case adjacente, je m’attends à ce qu’il reçoive la lumière.

Dans le cas du mur Nord ou Ouest (le mur rouge face au coffre par exemple), vers la fin de la vidéo, coffre fermé, le squelette marche devant, si nous sommes dessus, le mur est noir, du fait que l’origine de la lumière et le mur sont sur la même position et annulent par conséquent leurs vecteurs.

La solution a été de déplacer virtuellement la coordonnée de la lumière par rapport à la zone du bloc occupée (N, S, E, O), ainsi l’effet est correcte.

On est loin d’avoir fini, il reste des textures de normales à faire (et c’est looong), des affinages sur le shader (encore et toujours), et sinon le reste de la liste :

  • Ajouter le tri des lumières, sur l’intensité j’imagine dans un premier temps, et réfléchir au cumul/fusion ensuite,
  • L’axe Z, les hauteurs et peut être les occlusions (on peut rêver),
  • L’éclairage global de la scène (facultatif), c’est déjà elle qui pilote, donc ça devrait être facile.

When the lights are smooth & good

Pour comprendre il faudra plonger dans le passé avec l’article Smooth crimilight, je vous laisse réfléchir aux titres comme d’hab’ :p (merci William Onyeabor). Ce n’est pas un grand article plein de vérités mais un beau « fix » qui mérite son illustration.

Après pas mal d’essais, suite à l’article précédent (Des aNormales), j’ai pu corriger 2 choses importantes :

  • L’intensité des faces éclairées,
  • Le clignotement quand on se déplace.

L’intensité des faces

Quand vous vous éloignez d’une face qui reçoit de la lumière, celle-ci augmentait d’intensité, ce qui est la réaction inverse que celle normalement attendue. Il s’agissait en fait d’une normalisation manquante quant à notre vecteur qui nous donne la direction entre nous et la source de lumière. La distance augmente, donc le vecteur aussi, donc le résultat de la multiplication est pus forte. Si on normalise, seule la direction perdure.

Le clignotement de la mort

En fait, comme nous utilisons une liste de lumières au maximum définit (ici 2), mais que nous pouvons rencontrer plus de lumières que le maximum autorisé (tri des lumières à définir et cumul à calculer), et que pour nous déplacer on divise la lumière en 2 (source et destination directe), nous atteignons le maximum, du coup des lumières manquent pendant le déplacement mais sont présentes à chaque étapes ou à l’arrêt, ce qui nous donne cet effet de clignotement.

Autre point qui perturbait l’affichage était le cumul des 2 lumières pendant le déplacement ce qui a été corrigé, revu, perdu, revenu en arrière, pesté, et au final on en reste avec notre solution d’origine, mais l’explication du maximum de lumières est à tenir en compte.

Le dégradé d’éclairage pendant le déplacement

Tentative de vérification des valeurs des deltas pendant le déplacement entre 2 blocs lors des recherches

Une perte de temps considérable, c’est tout… L’idée était de remplacer les 2 lumières par 1 seule grâce au shader. Mais encore une fois j’ai oublié notre contexte et la transformation de notre idée pour les normales. Donc la solution est de faire comme avant, car on ne peut pas obtenir une information de distance 3D sur l’ensemble des éléments impactés par la lumière, ou il me manque une notion dans le shader et mes calculs, ce qui est plus que probable. Notez que c’est en cherchant à faire ceci que j’ai réglé les autres soucis.

Ceci étant dit, le résultat est tel qu’avant mais avec les normales en plus, donc au final on devrait être content.

Pour la suite, dans l’idée il faut :

  • Faire les textures de normales pour tout ce beau monde,
  • Regarder l’intensité, qui me semble un peu légère par rapport à avant, à comparer une fois qu’on aura tout,
  • Ajouter le tri des lumières, sur l’intensité j’imagine dans un premier temps, et réfléchir au cumul/fusion ensuite,
  • L’axe Z, les hauteurs et peut être les occlusions (on peut rêver),
  • L’éclairage global de la scène (facultatif), c’est déjà elle qui pilote, donc ça devrait être facile,
  • Reste cette histoire de spéculaire, mais le manque de dégradé au résultat malgré les notions de distance me font penser que c’est mort :/ .

Des aNormales

Suite à toute cette énergie de réactivation sur TARS, j’en suis venu à me remettre à rêver et à me demander quels résultats j’aimerai présenter à d’éventuels joueurs (un jour).

Nous sommes dans un univers 2D à l’apparence 3D, donc nous appliquons des principes, des formules, propre à l’univers 2D pour afficher un monde qui se veut 3D. Cependant, et j’en ai déjà parlé précédemment, la lumière est un calvaire. Alors vous me direz que les autres s’en sortent plutôt bien, certes, mais ils « trichent intelligemment », avec un décors en un morceau et plat sur lequel on peut ajouter les éléments qui vont vous immerger (ombre plate rotative sur sol par exemple ou effet de lumière par un calque d’obscurité); alors que TARS se veut proposer une gestion de la hauteur [Z] ce qui est super casse-couille on ne va pas se mentir, mais permet de donner beaucoup plus cette impression de « vraie » 3D.

J’ai donc repensé à mon éclairage et à notre élément de bloc :

Le bloc test

L’image, et c’est important, est rendue uniformément sur base de données calculées au changement dans la scène (déplacement, mouvement de caméra), c’est à dire que virtuellement on va tenir compte d’éléments éclairant, calculer qui est soumis à ces lumières et appliquer un modificateur de couleur, in fine. S’en suit une application par le shader qui va prendre cette information et la mélanger à la couleur d’origine de chaque pixel, en gros.

MAIS, les flancs de ce bloc, ou les côtés des arbres, coffre, murs ou tables, sont éclairés de la même façon que le sol ou le reste de l’image, on n’augmente pas le relief du bloc lui-même mais plutôt sur une vue d’ensemble de la scène.

L’idée est donc de renforcer la notion 3D de l’élément, son volume que l’on devrait percevoir. On le « lit » grâce aux lignes de l’image, notre cerveau comprend la forme, son relief, mais son éclairage est plat. Ce problème existe depuis de nombreuses années dans l’univers des jeux 3D, rappelez vous ces murs plats à la texture de mur, lisse alors que l’image nous indique un relief. Je ne vais pas vous révéler un grand scoop, mais depuis on utilise différentes techniques, comme le displacement mapping ou, dans notre cas, le normal mapping.

Le but du normal mapping est de pouvoir prendre une surface texturée et de vous donner l’impression, par son éclairage, qu’il est en relief. Une application serait de prendre un modèle très détaillé et donc très couteux au rendu, de faire une texture détaillée, de simplifier le maillage drastiquement, et d’appliquer cette texture détaillée, là on a un modèle simple avec un beau niveau de détails, une belle économie de calcul, mais la lumière ? Comment ce modèle va-t-il être éclairé ? Grâce à la texture détaillée, elle donnera, après traitement, des informations permettant de savoir dans quelle direction chaque pixel est orienté, la normal map; et au moment du rendu, grâce à un shader, la texture détaillée sera éclairée pixel par pixel correctement, le résultat sera bluffant et rapide. Pour les détails, internet regorge de ressources à ce sujet, ici je synthétise :).

Ce que la théorie nous apprends sur les textures de normales et leurs directions (source OpenGameArt)
La texture de normales de notre bloc

Alors moquez vous, c’est fait avec paint car je n’avais rien d’autre sous la main. Et, pour accélérer l’article et son mystère, vous l’aurez vite remarqué mais ça n’a pas l’air de coller à la théorie (on y reviendra, moment de solitude). En lisant la théorie vous lirez que l’on va utiliser les valeurs RGB de chaque pixel pour donner une direction spatiale au pixel (son vecteur, sa normale), RGB devient XYZ (la suite plus tard), donc j’ai transposé la théorie à mon plan isométrique, et avec mon cube la couleur est sans appel, on a un X pur (255,0,0 = rouge) sur le franc droite, un Y pur (0,255,0 = vert) sur le flanc gauche et un Z bien plat au dessus (0,0,255 = bleu).

On modifie le code pour charger, stocker, référencer et définir notre texture de normales et l’envoyer jusqu’à notre shader existant (ça a déjà pris pas mal de boulot).

Premier essai de contrôle pour voir que la texture est bien là

Mouai, il y a une couille dans le pâté. Les objets « non cube de sol » ont la texture aussi. Vous le verrez à la déformation (les arbres). Sinon on voit que le coffre éclaire toujours en rouge (la petite part de tarte rouge et bleue).

Premier essai en ne tenant pas compte des objets qui n’ont pas de texture de normales.

Maintenant que j’ai les bonnes infos (si seulement) je me dit qu’on peut tenter d’appliquer ce que les grands esprits ont fait avant moi, je vous épargne le code et les modifs TARS.

Premier essai de rendu avec normales

On a donc une scène qui ne veut pas prendre les flancs des blocs en compte et cumule trop de lumière avec un assombrissement au niveau du coffre qui est étrange. Le reste est noir, non impacté par une lumière, et là on se dit qu’une lumière globale serait intéressant, mais ça ne l’est que dans l’esprit de la scène (un extérieur boisé), donc à laisser à la discrétion de chaque scène.

On isole tout et on essaye de comprendre ce que l’on a

Je retire tout, je laisse le sol et notre héro porteur de la lumière (voq) et on va tenter de manipuler notre shader pour vérifier, car on ne peut pas débugger un shader « pas à pas », malheureusement.

On remet le coffre pour valider la seconde source de lumière
On discrimine les affectés pour afficher et contrôler la portée des effets

Bon, qu’est-ce qu’on essaye de faire ? On a une liste de lumières qui affecte l’éclairage et la couleur de chaque pixel d’un élément rendu (ici notre bloc), ce qui se calcul avec la valeur normalisée de notre pixel de la texture de normales (à la même position bien sur) que l’on veut comparer au vecteur de la source lumineuse (une à une) pour en déduire l’impacte d’éclairage (un peu, beaucoup, passionnément, …) et ainsi composer, lumière après lumière, la couleur finale de notre pixel venant de la texture. Ce sont les normales de la texture de normales qui donneront l’effet de variation d’intensité des flancs.

La théorie 3D nous parle de matrice de l’univers, mais nous sommes à plat, et donc on ne peut pas appliquer simplement la formule, on doit la tordre, et je ne suis pas matheux, alors ça se complique. De plus, comme dit plus haut, on a fait une erreur, notre image représente une dimension isométrique et la texture de normal a été faite en ce sens, après tout, comparer des vecteurs ça devrait rester équivalent. Encore un point qui ne nous aide pas avec les formules (voir les sources en fin d’articles pour les curieux).

Différents tests et autant d’échecs. J’avoue, là, j’ai fait une pause de 2 jours après 2 jours dessus. Il me manque un truc mais je ne sais pas quoi. Les couleurs sont violentes ou noires, les flancs ne se colorient pas comme attendu, tout disparait (noir), … Foutu vecteurs.

Troisième essai où l’impacte des flancs se montre

On arrive enfin à quelque chose, mais en contre partie, les objets sans texture de normales sont noirs, peu grave, ça viendra plus tard. Le truc ? Tout reprendre et reconstruire le shader avec les 2 principes communs aux articles parcouru : normalisation et produit scalaire.

Pour faire simple, on définit notre pixel de texture et notre pixel de la texture de normales (texel), on a 2 vec3 (rgb), on normalise le texel, on crée un vec3 à 0.0 pour notre couleur résultante (donc noir par défaut, cf. la mise en noir cité plus haut), on boucle sur la liste des lumières et pour chaque on calcule un facteur lumière qui est le résultat du produit scalaire de notre normale et de la position de la lumière par rapport au bloc, ce qui nous donne un coefficient que l’on va utiliser pour multiplier le pixel de la texture, la couleur de la lumière, la force de notre lumière avec ce coefficient, ceci ajouté à notre vec3 couleur. Au final on indique d’utiliser ce résultat sans oublier l’alpha de la texture d’origine.

Comme vous le voyez ça clignote, c’est dû à l’ancienne manière de faire le passage de la lumière d’un tile vers un autre, en passant un delta de 0 à 50% jusqu’à changer de tile, pour se faire on ajoute un seconde lumière pour la différence du delta sur l’autre tile. Ce qui nous amène à constater ce qu’il nous manque encore :

  • Transformer le delta et l’envoyer au shader pour le gérer là, sinon nous en revenons au changement sec d’éclairage de tile pendant le déplacement, au lieu du côté doux que l’on a développé.
  • Gérer la hauteur de l’éclairage (Z), mais cela va demander à TARS de passer l’information et d’en tenir compte.
  • J’ai pensé à l’occlusion et au barrage mais ça me parait compliqué à ce stade.
  • Ajouter un éclairage global ? Ça peut-être intéressant pour sa différence.
  • Créer les textures de normales pour les autres objets.
  • Un des articles parle de la lumière spéculaire et je me demande si nous saurions l’ajouter nous aussi.

Voilà donc la semaine de travail sur les normales, avec, heureusement, un résultat encourageant.

Sources

Et si on revenait un peu à TARS

Mélange des origines avec la map de test

Petit point de situation suite à la mise à jour Angular 17. J’ai donc repris le POC TARS et sa carte de départ, j’ai nettoyé quelques POCs (CodingPark et le début du système de particules) et … ça ne marche plus :/ ?

Plus d’une fois j’ai eu le malheur d’un craquage complet avec un beau message d’erreur… et là je valide le fait que le message d’erreur est effectivement amélioré. Alors merci chère mémoire de ne pas m’aider à me rappeler pourquoi j’ai coder ça ainsi et merci les 4 ans passés à évoluer et donc ne plus écrire ou penser de la même manière. Ceci dit j’étais quand même dans le pétrin.

Je dirais principalement que le fait de ne plus compter sur les effets de bords du typage aide pas mal à avoir des soucis, du coup merci le mode stricte ^^. Puis surtout, en terme de documentation c’est tellement plus confortable.

C’est là aussi que l’on voit que l’on code en « happy path » et sans gestion rigoureuse d’erreur sur projet perso. Le « je sais ce que je fais car je l’ai fait » c’est bien, mais 4 ans plus tard… comment dire, ça laisse à désirer. Du coup « oui », TARS a des lacunes. J’ai tenté de dire que le sol était un sol et il n’aimait pas, imaginez ma surprise… et il fallait lui dire que c’était une « image »… logique ! Et pourquoi ? Du fait que la description de l’élément ne répondait pas au standard ISO(métrique) demandé, mais sans gestion d’erreur ça explose ailleurs et tu peux chercher longtemps pourquoi.

Comme le fait de cibler un élément d’un tableau sur son index, de modifier le tableau et d’avoir une erreur d’accès à une méthode sur une instance… tellement évident… heureusement ça c’est le côté POC pour générer la map et tester le moteur, l’idée étant de passer par l’éditeur ensuite qui lui ne fera pas d’erreur. Mais son opérateur, ça…

Ah et en passant, tant qu’à faire de l’amélioration générale, n’oubliez pas que A || B n’est pas pareil que A ?? B (petit lien sympa). Et que les ternaires A ? A : B peuvent devenir A ?? B.

Mais aussi que les pipes RxJs peuvent s’agrémenter de code perso et ainsi pouvoir charger un Object { a: 1, b: 2 } vers un tableau [{ name: a, value: 1 }, {...}].

Ou encore qu’on ne peut pas retourner la déclaration d’un enum pour accéder à ses clefs (return SO0), mais qu’il faudra retourner ses clefs directement (return Object.values(SO0)).

Enfin voilà, en gros, tout est une bonne grosse question de type et de ce que l’on peut faire avec. Il y aurait encore à en dire, mais au final je n’ai pas retenu ces directions, et sans les notes il sera dur de vous en montrer plus.

Il y a bien sûr encore pas mal de boulot, je vais repasser progressivement, à temps perdu, dans l’ensemble du POC et de TARS lui-même, faire un coup de clean et de typage massif, gestion d’erreur(s), et surement de refactorisation plus ou moins importante, par exemple la suppression du mode 2D, ce qui aiderait quelques petite performances; mais aussi le fait rencontré que le validateur de chemin n’ait pas l’information disponible de la différence de hauteur/niveau/élévation entre 2 Coords en comparaison, et qu’il sera difficile en l’état d’y arriver. Du coup, on reparle de théorie du système, doit-il gérer Z ou pas, gros débat (ombre portée dynamique, lumière, changement de niveau (hauteur), …).

Quand on pense qu’au départ de ce moment je voulais juste reprendre le dev de l’éditeur…

Y replonger, bien que difficile, chronophage et casse-gueule avec le temps et la mentalité différente aujourd’hui, est amusant, un petit challenge sur le côté, et surtout : il fonctionne, pour ce qu’il est, ses limites et possibilités.

Je me tâte toujours, est-ce que Nahyan peut être exploré en isométrique ou doit-on absolument le voir en 3D ?

Angular : 4 ans, 9 versions

Début 2019 on a Angular 8, et en cette fin 2023 on a reçus la 17, soit 9 versions ! Et pourquoi je vous dis ça ? Tout simplement car j’ai voulu mettre à jour le POC de TARS, et ça ne s’est pas du tout passé comme prévu malgré l’usage du site de migration qui est bien fait.

Le truc c’est que ce n’est pas exempt de soucis qui se pose au fil du temps. Par exemple, certaines commandes vont exécuter l’install (npm i), mais il vous faudra reprendre à la main en précisant le flag --legacy-peer-devs ou encore des résolutions d’arbre de dépendances qui ne se font plus correctement.

J’ai réussi non sans mal à passer à la version 11 mais pas plus loin, la quantité de conflits ou de soucis a explosé, pourtant le projet n’a rien de particulier d’un point de vue Angular.

Du coup j’ai opté pour une autre approche. Le projet n’utilise Angular que comme une coquille de lancement avec l’usage de l’injecteur et du client HTTP. J’ai donc créé un projet frais et j’ai replacé le code en dedans et là : BOOM ! Fallait pas oublier qu’on était passé en mode stricte, ou que le côté standalone a changé la manière de monter un module ou de gérer les composants, bref il ne s’en sortait plus.

On sort l’huile de coude, on repasse partout, on s’arrache les cheveux sur le typage fort qui me manquait à l’époque et on solutionne avec quelques generic. On ajuste la nouvelle mouture standalone avec les imports et providers locaux et ça tourne ! Enfin, oui, mais ça nécessitera quelques heures avant que le builder arrête de péter une durite. À savoir que si vous avez un couac il peut perdre la compréhension de tout les tags Angular et votre erreur c’est 1 détails quelques part… Bonne chance.

J’en profite pour vous filer un tuyaux : penser à définir vos composant en standalone, rajouter ce qu’il vous faut localement comme CommonModule ou HttpClientModule.

Un truc qui continue de m’agacer, c’est ce double état undefined et null, car si vous spécifiez un attribut de classe comme optionnel vous utiliserez un ? à sa fin, mais si vous l’assignez suite à un find d’un tableau par exemple, ce qui peut vous rendre votre type ou null, vous aurez un conflit possible de type, sauf si on parle de test(s) avant assignation ou autre écriture à rallonge; ça manque quand même de cohérence.

Enfin soit, c’était amusant d’y retoucher, avec l’objectif de reprendre son éditeur en main, toujours pour ce projet éternel qu’est Nahyan.

Angular 17 recèle d’un tas de nouveautés qui, je pense, pourraient, avoir leur rôle à jouer, voire en refactorisation, mais je m’avance un peu. Par exemple Signal, la sortie de Zone, la nouvelle syntaxe de templating, etc.

TipTap Kodak

TipTap est un éditeur WYSIWYG que l’on doit mettre en place à la main (headless) basé sur ProseMirror. En gros vous avez un kit de départ et après vous activez des extensions. Dans mon cas, je l’ai utilisé dans un contexte Vue3 avec Prime pour en faire un composant d’édition en mode JSON dans le but de l’envoyer vers une moulinette pour obtenir un PDF en sortie et au passage variabiliser la structure. Partageons quelques XPs !

Ce qu’il faut savoir c’est que tout se base sur un JSON (JSONContent) qui est traduit en HTML au sein de l’éditeur via les extensions qui parsent et traduisent chaque élément de la structure en balises et attributs. En soit, sorti de sa boite, c’est très simple de mise en place et d’usage, on dit à un bouton d’exécuter une commande et suivant la sélection dans notre texte, l’effet sera appliqué. Là où cela se complique c’est de bien comprendre ces fameuses extensions que l’on voudra s’empresser de coder et modifier pour obtenir nos résultats attendus.

La documentation, bien que bien faite, manque de cas d’usage quand on descend dans le terrier du lapin blanc. Là on peut s’arracher les cheveux. D’un côté on a l’implémentation de ProseMirror par TipTap et de l’autre la couche de TipTap pour organiser leurs extensions/marks/nodes, et là rien n’est évident. Et pour ne rien arranger, HTML est déjà assez spécifique sur quasi chaque tag :

  • Un titre, c’est un tag H + un chiffre de 1 à 6. Donc un niveau de titre à transformer au rendu.
  • Une liste, c’est un bloc contenant des items, qui à leur tour contiendront du contenu (JSONContent déclare les contenu en tableau) et ce contenu est forcément un paragraphe qui contient un noeud texte.
  • Le gras, italique, barré ou souligné altèrent un sous ensemble de contenu (la sélection) dans un nouveau noeud texte avec une marque (mark) qui sera traduite au rendu (ajout de balise, attribut, …)
  • Le surlignage par exemple, comme dit ci-avant, prendra en plus un attribut (la couleur)

Et là on reste sur la base. Dans mon cas j’ai dû jouer sur de l’indentation de contenu, de la variabilisation de contenu et un saut de page.

Saut de page

Pour le saut de page j’ai opté pour transformer le hr (setHorizontalRule) en repère interprétable dans la moulinette PDF, et en CSS c’est également un tag visible manipulable. Ça c’est du détournement du fait que je n’avais pas l’usage du tag existant, facile.

Indentation

Pour l’indentation, c’est une autre affaire, et là on creuse les extensions. Je me suis basé sur l’extension de Evan Payne, en ajoutant un set défini pour fixer l’indentation directement à telle position.

    return {
      indent: changeIndent(1),
      outdent: changeIndent(-1),
      setIndent:
        (level: number) =>
        ({ tr, state, dispatch }) => {
          const { selection } = state
          tr = tr.setSelection(selection)
          tr = updateIndentLevel(tr, level, true)

          if (tr.docChanged) {
            dispatch?.(tr)
            return true
          }

          return false
        },
    }

Une extension

Une extension c’est un nouveau module pour tiptap.

export const Indent = Extension.create<IndentOptions>({

On déclare en amont les options du module (IndentOptions) qui contiendra les variables que l’on peut définir via la configuration (quand on le déclare dans l’éditeur), tel que

const editor = useEditor({
  extensions: [
    StarterKit.configure({
      listItem: false
    }),
    Underline,
    Highlight.configure({ multicolor: true }),
    TextAlign.configure({ types: ["heading", "paragraph"] }),

On s’ajoute au module en définissant l’interface des commandes qui seront implémentées par notre extension

declare module "@tiptap/core" {
  interface Commands<ReturnType> {
    indent: {
      indent: () => ReturnType
      outdent: () => ReturnType
      setIndent: (level: number) => ReturnType
    }
  }
}

Pour le reste je vous laisse dans les mains de la documentation dédiée aux extensions.

Variabilisation

Enfin, pour la variabilisation, là on doit parler de plusieurs choses :

  • Remplacement de valeurs, par exemple identifées par [mavariable] ou {…} et autres possibilités
  • Dynamiser du contenu :
    • Section visible, ou pas, selon un paramètre (condition)
    • Répéter une section autant de fois que le paramètre contient d’éléments et remplacer le contenu (cf. premier point de la liste). (boucle)

Attribut

Voilà bien un beau problème, comment identifier un noeud de notre JSONContent et le mettre en rapport avec une variable pour le conditionner ? De plus, ce noeud doit être édité par l’interface éditeur et non en direct dans le JSON. Notre objectif va donc, suivant la position du curseur, d’éditer un attribut sur le bloc courant afin de laisser un marqueur identifiable pour effectuer le remplacement.

Pour faire très très simple, TipTap a une méthode

updateAttributes(tag, { varName: newVarName })

Et donc il faut connaitre le tag d’application, mais aussi que celui-ci connaisse l’attribut, sinon cela sera sans impact.

Dans le cas d’un item de liste, on peut créer une extension d’une extension existante, on l’agrémente d’un nouvel attribut

export const CustomListItem = ListItem.extend({
  addAttributes() {
    return {
      varName: {
        default: null
      }
    }
  }
})

Plus simple tu meurs, au final, car pour arriver à ce résultat…

Du coup, si un bouton déclenche une popup pour nous demander le nom d’une variable et que l’on souhaite l’appliquer sur notre élément de liste

editor.value?.chain().focus().updateAttributes("listItem", { varName: "mavariable" }).run()
État de l’éditeur (mode navigateur)
État du JSONContent

En l’état, nous pouvons détecter ce noeud et boucler une liste d’éléments par exemple, vive la récursivité, en mappant le contenu de cet élément de base comme template pour les variables et structure.

Bloc de section

Est-ce que l’on peut mettre un attribut sur un autre type de bloc pour obtenir un autre comportement : oui, mais… Dans notre cas, nous voulons qu’un ensemble d’élément soit masqué/visible selon une condition ou répéter si la variable conduit à un tableau. Nous allons devoir ajouter une encapsulation et intégrer notre variable comme vue précédemment. À l’image d’HTML, nous déclarerons une balise « section » pour représenter ce regroupement.

Cette fois-ci nous voulons créé un nouveau noeud et non étendre, du coup le code n’est pas le même

export const Section = Node.create<SectionOptions>({
  name: "section",

  addOptions() {
    return {
      HTMLAttributes: {}
    }
  },

  content: "block+",

  group: "block",

  addAttributes() {
    return {
      varName: {
        default: null
      }
    }
  },

Et on utilisera la commande toggleWrap pour encapsuler/désencapsuler (toggle) dans notre déclaration de commande pour notre noeud.

  renderHTML({ node, HTMLAttributes }) {
    return [`section`, mergeAttributes(HTMLAttributes, { varName: node.attrs.varName }), 0]
  },

  addCommands() {
    return {
      setSection:
        (attributes) =>
        ({ commands }) => {
          return commands.toggleWrap(this.name, attributes)
        }
    }
  }

On appellera notre commande en lui passant le nom de notre propriété et sa valeur, ceci dit, nous aurions pu utiliser toggleWrap sur notre nouveau noeud puis updateAttribute.

Évidemment en comprenant l’effet toggle, nous aurions peut-être mieux fait de prévoir un wrapIn (encapsuler) et un lift (remonter/désencapsuler) et écrire de nouvelle commande en faisant des appels à ProseMirror. Ceci ne dépend que de ce que vous voulez proposer et couvrir comme besoin.

Modification du JSONContent

Maintenant que l’on a nos attributs de variable et nos 2 objets outils, nous allons pouvoir opérer. Soit on passe par la récursive qui transforme notre JSONContent en PDF et on interprète nos cas supplémentaires, soit, pour l’avoir en éditeur on parse et transforme le JSONContent directement, ce que nous ferons (mais les 2 fonctionnes pour l’avoir réalisé).

En gros on a notre structure source et on va créer, étage par étage notre nouvelle structure en modifiant le contenu au passage et on en profitera également pour effectuer les remplacements de valeurs.

str.replace(new RegExp("\\[" + k + "\\]", "g"), map[k] as string)

Design

L’avantage secondaire d’avoir des attributs ou balises identifiables sera la possibilité via CSS d’agrémenter votre visuel et indiquer là où il y a une variabilisation.

La difficulté sera de faire correspondre votre résultat visuel entre l’éditeur et votre sortie PDF ou web.

Conclusion

TipTap semble facile, l’est un peu puis devient bien complexe quand on veut creuser, j’ai l’impression de dire ça à chaque fois que je creuse ahah. Je vous recommande de lire leur code et d’aller creuser du côté de ProseMirror pour parfaire le niveau de vos devs et de votre compréhension.

jsPDF le nouveau FPDF

FPDF pour moi reste une référence en terme de librairie bas niveau pour créer vos PDF et j’avais fait mon premier système (et d’autres) de facturation avec; et récemment j’ai découvert un successeur plutôt digne côté front : jsPDF.

Quand on parle de bas niveau c’est d’une part car vous avez la main et devez gérer quasi absolument tout vous-même (coordonnées x,y, occupation de l’espace, vos marges, vos retours à la ligne ou saut de page, …) et de l’autre car la librairie va écrire pour vous le code PDF natif (https://pdfa.org/).

jsPDF n’est pas le nouveau messie dépourvu d’erreur, elle en a son lot, parfois même agaçante vous demandant pas mal d’effort ou d’ingéniosité, mais dans le paysage actuel c’est une base plus que correcte. Attention toute fois de ne pas confondre convertisseur de contenu HTML vers PDF et jsPDF qui vous permet de le créer/composer. Les convertisseurs, au super rendu et 3 lignes de code, utilisent la librairie canvas pour faire une forme de capture d’écran et injecter l’image dans un PDF, joli mais sans accessibilité (sélection du texte, c’est une image) ni modifications (pour la même raison que c’est une image).

Bien que la documentation existe, elle reste sommaire et j’ai moulte fois dû, et au final préféré, une lecture directe du code disponible sur github. Il est certain que si vous entrez dans des réflexions de faisabilité, de type de paramètre ou de compréhension, vous descendiez à ce niveau régulièrement. Notamment un éternel sujet complexe : la taille d’un texte.

But de l’article

Cet article à pour but de dégrossir des points d’attention pour ceux qui veulent se lancer dans l’écriture de PDF. Il n’est pas aisé sans notion infographique minimum de concevoir un document qui aura du sens et respectera quelques notions « élémentaires ». J’entend par là la notion de marges, de taille de texte, de police d’écriture et de son style, ou encore d’interlignage, d’alignement, d’indentation et j’en passe.

Les points

Dans ce monde vous aurez accès à divers unité, mais je trouve qu’il est plus aisé de rester natif et d’utiliser les points, tel qu’un millimètre = 2.83465 points (se référer aux pouces du coup). Ou encore, une A4, aura pour largeur 595 pts et une hauteur de 842 pts. Et pour compléter ce sujet, un pixel aura un rapport de 0.75 pour 1 pts (pensez à l’unité de vos images par exemple).

Les couleurs

Elles peuvent être gérée par code hexadécimal (#333333) ou par RGB/RGBA (channel(s) en jsPDF), ainsi nous utiliserons une structure, qui prendra place en ch1, ch2, ch3 et ch4 tel que :

export interface RGBColor {
    r: number,
    g: number,
    b: number,
    a?: number
}

Classe de base

Afin de mieux gérer notre contexte, je vous suggère de créer une classe qui vous permettra de conserver votre position x, y, vos marges, les dimensions calculées d’espace disponible, puis la fonte choisie, sa taille, son interlignage, etc.

import { jsPDF } from "jspdf";

export interface Margins {
    left: number
    right: number
    top: number
    bottom: number
}

export const DIM_A4_POINTS = { width: 595, height: 842 }
export const DIM_MM_POINTS = 2.83465
export const DIM_PX_POINTS = 0.75 // 1.333

export class KPdf {
    doc: jsPDF

    margins!: Margins // Defined in constructor

    x: number
    y: number
    pageWidth!: number // Updated by constructor call
    pageHeight!: number // Updated by constructor call

    fontSize: number = 10
    lineHeight: number = 12

    constructor() {
        this.doc = new jsPDF("p", "pt", "a4")
        this.setMargins(20 * DIM_MM_POINTS)

        this.x = this.margins.left
        this.y = this.margins.top
    }

    setMargins(margins: number): KPdf;
    setMargins(left: number, right?: number, top?: number, bottom?: number): KPdf {
        this.margins = right ? { left, right, top, bottom } as Margins
            : { left: left, right: left, top: left, bottom: left } as Margins
        this.updatePageDims()
        return this
    }

    updatePageDims(): void {
        // A4 forced format on portait orientation
        this.pageWidth = DIM_A4_POINTS.width - this.margins.left - this.margins.right
        this.pageHeight = DIM_A4_POINTS.height - this.margins.top - this.margins.bottom
    }
...

Cadeau, je vous ai résumé ici quelques éléments de base, voyons ça ensemble.

Marges

Les marges, donc l’espace réservé depuis chaque bord de votre feuille, seront quasi indispensable dans vos calculs et positionnements, l’objet va nous aider à nous y retrouver. L’interface est claire assez, il vous manquera un getMargins() pour l’usage mais je vous laisse l’ajouter ;).

J’ai utilisé ici un double setter permettant de setter les 4 bords d’un coup, égaux entre eux, comme dans le constructeur ou de pouvoir appeler sa version individuelle. Un énième alternative serait le passage d’un objet typé Margins, amusez vous, c’est selon vos besoins :).

Noter le type de retour (KPdf), pour ainsi pouvoir chaîner vos appels, quel confort quand même ;).

Constructeur

Le but est de créer notre base jsPDF correctement paramétré selon nos habitudes, besoins et choix technique, dans notre cas des pages en portait (« p »), en utilisant l’unité point (« pt ») et de dimension A4 (« a4 »).

Une fois établi, on met des marges par défaut, typiquement comme tous les traitement de textes qui vous proposent un modèle clef en main par défaut que personne ne retouche, sauf des gars comme nous ^^. C’est là qu’une première magie peut s’opérer, si on a des marges, connait alors notre origine (x,y) et l’espace disponible pour y mettre notre contenu.

J’ai également mis une taille de caractère (fontSize) à 10pt et son interlignage (lineHeight) à 12 en prenant l’échelle 1.2.

Et voilà vous êtes parti, bon amusement !

… si seulement ^^

Agrémenter KPdf

Vous avez une base, que vous ferez évoluer selon vos besoins au cas par cas de vos projets, perso je me suis mis des getter/setter pour x, y, xy, fontSize, lineHeight, mais aussi pour définir une en-tête et un pied de page. Là, je vous parlerai de delegate ou callback, un moyen de définir de manière extérieur un contenu à une méthode interne actuellement non définie.

export type HeaderPrototype = (pdf: jsPDF) => void

export class KPdf {
...

    headerFct?: HeaderPrototype
    footerFct?: HeaderPrototype

    setHeaderFct(fct: HeaderPrototype): KPdf {
        this.headerFct = fct
        return this
    }

    setFooterFct(fct: HeaderPrototype): KPdf {
        this.footerFct = fct
        return this
    }

    addHeader(): KPdf {
        if (this.headerFct) {
            this.headerFct(this.doc)
        }
        return this
    }

    addFooter(): KPdf {
        if (this.footerFct) {
            this.footerFct(this.doc)
        }
        return this
    }

    newPage(): KPdf {
        this.doc.addPage()
        this.addHeader().addFooter()
        this.setXY(
            this.margins.left,
            this.margins.top
        )
        return this
    }

...

Exemple ici avec la méthode newPage qui ajoutera une page au document final et appellera de toutes façons l’ajout d’une en-tête et pied de page, évidemment si définit par l’utilisateur (détecté en interne de méthode). Bonus ici, reset des x et y au saut de page.

En démarrage par contre, car jsPDF crée une page par défaut, il faut les ajouter à la main, tel que :

const kpdf = new KPdf()

// Setters, margins
...

// Manage first page
kpdf.addHeader().addFooter()

Gestion du texte

J’ai eu 2 cas différent en terme de traitement de contenu, un premier avec une source Tiptap, dont je parlerai surement dans un autre article sous forme de retour d’expérience, et un autre avec une gestion de cellule de tableau maison, dont je vais un peu vous faire quelques retours.

On utilisera la fonction text pour écrire du texte dans notre PDF et petit conseil en passant, mettez l’option baseline à « top » mieux gérer votre x/y. Autre chose à savoir, si vous voulez aligner à droite, votre x est le x en fin de ligne/bloc et il faut également mettre l’option align à « right » du coup.

Retour à la ligne

Ce n’est pas aussi bête qu’il n’y parait, même si la fonction text à l’air complète elle manque clairement de documentation, cas d’usages clairs.

C’est quoi un retour à la ligne, c’est d’arriver au bout de notre espace disponible, de forcer un retour chariot au début de notre ligne (x), quel qu’il soit (pas forcément la marge gauche, surtout si indentation), de passer une hauteur de ligne (augmenter notre y) et de continuer d’écrire notre contenu (on parle ici de rendu du texte que l’on veut injecter dans le PDF).

La question est comment on sait ça ? Posez-vous juste la question. Vous avez une somme de caractères (un paragraphe par exemple), j’imagine que vous connaissez la taille du texte et que vous êtes prêt de ce côté kpdf.doc.setFontSize(12) par exemple. Sans faire du suspens inutile, je vous conseille splitTextToSize qui va vous rendre un tableau de string coupé à distance maximum (votre pageWidth par exemple ou la largeur d’une cellule). Si vous injectez le résultat directement dans text, pensez à préciser l’option lineHeightFactor (1.2 dans notre cas), personnellement, pour d’autres raisons, j’ai géré à la main l’écriture de chaque ligne, donc j’utilise ma variable lineHeight et je positionne mon x et y également à la main.

Gras/italique

Si vous avez du gras ou de l’italique dans votre texte d’origine vous risquez d’avoir des surprises, déjà la fonction text ne gère qu’une configuration à la fois, c’est à dire que si j’ai une phrase telle que : « mon contenu est composé », j’aurais virtuellement 3 configurations :

  • « mon » : texte normal
  • « contenu » : texte gras
  • « est composé »: texte normal

Attention, ici on parle au sein d’une seule ligne, c’est encore plus fun (compliqué) quand on gère ça en multilignes. Qu’importe votre façon de gérer cette structure de bloc, vous allez devoir boucler sur cette liste, calculer pour chaque la longueur du bloc de texte et ainsi gérer un x progressive par appel de la méthode text. Grossièrement on aurait (on suppose la gestion des espaces entre blocs) :

this.doc.text("mon ", x, y, { baseline: "top" })
// Change style
this.doc.text("contenu ", x + w1, y, { baseline: "top" })
// Change style again
this.doc.text("est composé", x + w1 + w2, y, { baseline: "top" })

On voit facilement l’intérêt de la boucle pour faire progresser x successivement. Cela suppose que votre gestion de bloc retiendra la largeur de celui-ci une fois calculé.

Multiligne

Si on pousse le bouchon où le dernier bloc est trop large par rapport à votre pageWidth/taille de bloc, alors on peut subdiviser en coupant au mot (textPart.split(‘ ‘)) et tester chaque mot un à un, puis forcer le retour à la ligne et reprendre. Vous aurez un algo maison combinant par exemple getStringUnitWidth ou encore splitTextToSize et votre gestion d’effets.

Dans mon cas Tiptap, ayant dû gérer pareil cas, j’ai fortement utilisé getStringUnitWidth, mais qui a sa limite concernant le fait qu’il ne gère pas le gras/italique, il faut appliquer un facteur empirique (~1.08) ou trouver le descriptif par caractère de la fonte utilisée pour les cas : gras, italique, gras+italique; et réécrire le calcul de largeur d’un texte donné. C’était le même soucis avec FPDF, tout dépend des métas de la fonte utilisée, mais là j’admet que ça dépasse mon niveau sur le sujet.

Saut de page

Comme on l’a vu pour le retour à la ligne, c’est pareil en y, si on voit que ce que l’on va écrire va déborder au delà de la marge inférieure alors il nous faut créer une nouvelle page tel que vu en exemple de code plus haut, avec en-tête et pied de page, repositionner x et y et reprendre notre rendu. Plus simple quand on a compris le retour à la ligne.

Il faudra cependant prêter attention à l’indentation. Par exemple, si vous êtes en train d’écrire les éléments d’une liste, vous aurez l’indentation pour dessiner la bulle par exemple, et si vous gérer plusieurs niveaux d’imbrication alors là encore plus (pensez récursivité). Hors des listes, l’indentation existe telle que dans un traitement de ligne quand vous utilisez la tabulation ou une règle de positionnement de début de paragraphe.

Tableaux

Un tableau c’est quoi ? Au delà d’une structure à 2 dimensions, ce sont des espaces délimités en largeur (mais pas que, parfois) dans lesquels doit prendre place un contenu. Sans parler des considérations esthétique, de formes et de couleurs, nos cellules sont des blocs, successifs pour lesquels notre logique de retour à la ligne revient.

L’astuce esthétique et logique (x,y) nous oblige à garder en mémoire quelle cellule aura pris le plus de hauteur parmi la ligne courante, sinon, par exemple, si vous faites un zèbre (couleur de fond de ligne) comment l’appliquer sans créer des trous dans certaines cellule ? Vous êtes obligé de faire une passe de calcul (découpe des textes, retours à la lignes, gestion des blocs, …), puis une seconde passe de rendu en profitant des données calculées (couleur de fond, positionnement x,y).

Répétition de l’en-tête

Quand on parle de tableau on oublie souvent de définir ce qu’il se passe quand on saute de page. On aurait tendance à continuer de faire le rendu des cellules sans se tracasser, un peu comme certains tableurs, mais pourquoi ne pas répéter votre en-tête de tableau ? Cela demande une gymnastique à l’image de notre en-tête/pied de page, même logique, un poil plus complexe.

export class KPdf {

    cellPaddings!: Margins

    tableHeaderInitFct?: HeaderPrototype
    tableContentInitFct?: HeaderPrototype

    constructor() {

        this.setCellPaddings(0.5 * DIM_MM_POINTS)

    }

    // Table part
    addTableHead(columns: TableColumns[]): KPdf {
        if (this.tableHeaderInitFct) {
            this.tableHeaderInitFct(this.doc)
        }

        // X, Y, fontSize and LineHeight already set by user
        this.y += this.cellPaddings.top

        columns.forEach((c) => {
            const cellW = this.pageWidth * c.width

            this.doc.text(
                c.title,
                this.x + (c.align === "right" ? cellW - this.cellPaddings.right : this.cellPaddings.left),
                this.y,
                {
                    baseline: "top",
                    align: c.align
                }
            )

            this.x += cellW
        })

        this.y += this.lineHeight + this.cellPaddings.bottom

        return this
    }

    addTableContent(columns: TableColumns[], data: any[], zebraColor: RGBColor = { r: 240, g: 240, b: 240 }): KPdf {
        if (this.tableContentInitFct) {
            this.tableContentInitFct(this.doc)
        }

        let zebra = true

        data.forEach((d) => {
            let maxCellHeight = 0


            // Page break
            if (this.y >= this.margins.top + this.pageHeight) {
                this.newPage()
                this.addTableHead(columns)
                if (this.tableContentInitFct) {
                    this.tableContentInitFct(this.doc)
                }
            }
        })

        return this
    }

...

Donc entre 2 lignes du contenu de votre tableau, on va créer une nouvelle page, remettre l’en-tête et le pied de page, puis on va rappeler l’en-tête de tableau, qui va rappeler le style définit dans une fonction spécifique (et externe), puis remettre notre configuration de corps de tableau et reprendre notre rendu.

Notez que pour le coup nous aurons un nouveau jeu de marges à tenir en compte : le padding des cellules.

Sources

J’ai créé un gist publique avec le code de la classe k-pdf au complet pour ceux que ça intéresse, merci de mentionner le github de l’auteur en cas d’usage ;). J’ai également ajouté un code illustrant l’usage de ce qui a été vu ici, nettoyé, cela va sans dire.

Conclusions

Comme dit, cet article visait à dégrossir le sujet tout en faisant un retour d’expériences. Il n’y a certes pas qu’une façon de faire et le code peut sûrement être amélioré, n’hésitez pas à m’en faire part.

Images

Sujet quelque peu embêtant, car la seule manière trouvée efficace est la base64, pointer vers un asset local (VueJs, Vite) vers addImage ne fonctionne pas.

jsPDF
    .addImage(
        "data:image/png;base64,...",
        "PNG",
        x,
        y,
        wpx * DIM_PX_POINTS,
        hpx * DIM_PX_POINTS
    )

Ziggymacell on the prime route encore

Ou Ziggy route dans une cellule Datatable PrimeVue en mode composant de cellule.

Je ne vais pas répéter ici ce qui a été vu précédemment, ce que l’on cherche à faire est un composant utile et générique pour pouvoir rediriger l’utilisateur vers une route nommée avec les paramètres adéquats (variables donc) avec un libellé pouvant être basé sur les données de la lignes (rowData).

La route

Ziggy va alimenter notre objet route. Ce dernier contiendra la définition de notre router Laravel (pour rappel quand même). On a une route nommée : maroute-edit = /maroute/{id} .

Le composant de lien de cellule

On a besoin de lui passer l’url et le texte à afficher, mais on aimerait profiter du rowData pour construire ce texte et cette url (du fait des paramètres à passer), il nous faut donc un moyen d’intervention du côté de l’appelant.

Pour le texte on peut imaginer que le paramètre ne soit pas le libellé directement mais une fonction callback recevant rowData en params, nous permettant de retourner le string que l’on aura produit potentiellement avec.

Pour l’url c’est plus compliqué, on sait juste que l’on veut travailler avec des routes nommées, donc une propriété qui recevra le nom de la route, mais quid des paramètres ? Dans mon cas j’ai eu un besoin de l’attribut de route ‘id’, mais dans mon rowData c’est l’attribut xyz_id qui matchait et non l’id de ma row, du coup il nous faudrait un mapper qui serait un tableau de clefs avec la valeur à prendre dans le rowData.

<script setup lang="ts">
import { PropType } from "vue";

const props = defineProps({
    to: {
        type: String,
        required: true
    },
    text: {
        type: Function as PropType<(rowData: any) => string>,
        required: true
    },
    toParams: {
        type: Array as PropType<{ key: string, value: string }[]>
    },
    rowData: {
        type: Object,
        required: true
    }
})

function getRouteParams(): Object {
    const params = {}
    props.toParams?.forEach((tp) => {
        params[tp.key] = props.rowData![tp.value]
    })
    return params
}
</script>

<template>
    <Link :href="route(to, getRouteParams())">{{ text(rowData) }}</Link>
</template>

On notera que c’est Link et non router-link qui officie du coup, comme vu précédemment.

Rassemblement !

    {
        field: 'li_created_at',
        header: 'Dernière action',
        sortable: true,
        components: [
            {
                component: markRaw(LinkCell),
                props: {
                    to: "invoice-edit",
                    toParams: [
                        { key: 'id', value: 'li_id' }
                    ],
                    text: (rowData: any) => {
                        return rowData.li_num + ' (' + (new Date(rowData.li_created_at).toLocaleDateString()) + ')'
                    }
                }
            } as CellComponent
        ]
    }

En gros l’astuce sera dans props où l’on passera le nom de la route, le mapping de paramètres et la fonction callback pour rédiger le contenu. Ainsi on aura dans mon cas ce rendu avec lien fonctionnel.

ScrollPanHell

ScrollPanel est un composant PrimeVue (PrimeFaces) qui veut vous aider à faire défiler vos contenus dans un espace définit.

Démo PrimeVue de son composant ScrollPanel

La démo est sympa, ça donne envie. Mais ! D’une part, c’est fait en Javascript, positionné par calcul, sans proposer de moyen de définir le positionnement (pensez à un t’chat); d’une autre, les bar peuvent sortir de leur piste en débordement (cas du DataTable redécoré avec ScrollPanel), sans compter que sans survol de la zone, les bar ne s’initialisent pas et induisent l’utilisateur en erreur. Trop d’erreurs pour le garder sur un projet, ni même comprendre l’intérêt hors de leur usage type en démo.

Les tinybar, c’est pas nouveau, depuis que Webkit nous a donné la main sur le style on s’en donne à coeur joie, même si certains navigateurs d’époque ne voulaient pas suivre, ou comme Firefox, continuent de faire bande à part (Firefox propose une autre formule plus minimaliste). Du coup, si ScrollPanel ne veut pas faire correctement un job qui existe déjà, ni apporter un plus au fait de créer un composant qu’une simple classe CSS peut résoudre, on peut faire le nôtre.

<div class="scrollpanel">
  ...
</div>

Comme je le disais, une simple classe CSS. Ceci dit, n’oubliez pas qu’il faut restreindre votre contenu, donc vous définirez une hauteur et/ou largeur, même si notre composant aura des valeurs par défaut.

.scrollpanel {
  width: 100%;
  height: 100%;
  position: relative;
  overflow-y: scroll;
  overflow-x: auto;

  scrollbar-color: var(--blue-500) var(--blue-50);
  scrollbar-width: thin;

  &::-webkit-scrollbar {
    width: 9px;
    height: 9px;
  }

  &::-webkit-scrollbar-track {
    background: var(--blue-50);
  }

  &::-webkit-scrollbar-thumb {
    background-color: var(--blue-500);
    border-radius: 3px;

    &:hover {
      background-color: var(--blue-700);
    }
  }
}

Disséquons notre CSS. Déjà c’est écrit en SCSS car c’est plus cool 🙂 lisible, maintenable et organisé. Ensuite, j’ai utilisé des variables pour les couleurs comme on en trouve dans la plupart des librairies aujourd’hui, mais mettez ce que vous voulez : code hexadécimale, nom de couleur, rgb, … Enfin, il y a 2 solutions : on commence avec le préfixe scrollbar et on continue ensuite avec le préfixe -webkit.

scrollbar c’est la version Firefox de la solution, en mode minimaliste sans avoir toute la main sur ce que l’on veut avoir. Mais au moins on s’en approche et c’est toujours mieux que les scrollbar d’origine.

-webkit c’est la version webkit… bah oui, c’est comme le Port-Salut… Et là on profite de belles possibilités car les sélecteurs proposés nous laissent la main sur le CSS applicable sur la cible. ´Évidemment c’est non-standard, et en même temps c’est le moteur le plus répandu… allez comprendre. Du coup faites attention et n’hésitez pas à consulter caniuse ou MDN.

Au final vous aurez un système de scrollpanel, compatible et sans les erreurs de la version PrimeVue. Ceci dit, gardons un oeil dessus car PrimeFaces a un bon rythme de livraison et ils glissent souvent des correctifs intéressant dans leur livraisons mineures (ex: un fix virtual-scroll pour DataTable récemment), et ici, en préparant cet article, j’ai vu qu’ils ont ajouté une nouvelle mécanique pour permettre l’accès au style du DOM de leur composant avec une définition en paramètre (:pt pour Pass Through). Personnellement je préfère séparer mon CSS/SCSS (tel que leur thème et/ou une surcouche), surtout dans un esprit générique, car le passer en paramètre à chacun de nos usages générera une répétition de code, et ce n’est pas une bonne pratique (oui on peut faire un composant personnalisé qui englobe notre définition). Mais encore, le composant, bien qu’il évolue, ne propose toujours pas de fonctionnalité qui justifie son existence et cette façon de l’utiliser.