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
    )

Fusion de la chambre intermix

Suite à l’article précédent j’ai voulu faire un tour de nettoyage et je me suis rendu compte qu’un ancien démon revenait à la charge. Nous avons divisé le code par domaine et contexte de rendu, propre et héritant d’un parent commun, et dans un service je mets le code commun à ce qui concerne Grid, ou Iso etc., de manière à isoler les calculs de l’usage selon le contexte.

Schéma avant fusion

Plus simple avec un schéma, voici la découpe avant fusion. Ce qui nous intéresse c’est la séparation 2D et Gl, puis la découpe par usage/type à savoir Sprite, Grid ou Iso, puis les regroupements de type IsoElement/GridElement, ainsi que des services relatifs aux couches.

On a une sorte de matrice à 3 dimensions en ce qui concerne cette idée. Sauf que programmer ça, en TypeScript, ben c’est pas très évident. En PHP j’aurai pu utiliser des Traits, il existe des mixins en javascript mais non merci, je vous laisse vous faire votre avis mais ce n’est pas à la hauteur. L’héritage multiple n’existe pas (cf mixin), du coup il faut savoir se renouveler et faire preuve d’audace, d’expérimentation et de refontes inévitables. C’est ce que j’ai dû faire, non sans mal.

J’étais parti pour déplacer les fonctions de rendu dans les services par couche (Grid/Iso) et de fusionner Grid2DElement avec GridGlElement, vu que leur différence réside dans le contexte de rendu (2D/Gl). Mais je me suis aperçu que bien que je gagnais en clarté à tout regrouper, on augmentait d’autre part la difficulté de ce même code et des approches. Bref, bien, mais pas bien.

Du coup revirement de situation et revenons sur nos pas de plusieurs mois quand on a justement décidé de diviser par contexte de rendu, quand les lumières sont arrivées (la version solutionnée). Revenons donc à cette idée non divisée et sans emmerder les services déjà très bons tels quels.

C’est la fusion !

Fusionner Grid2D et GridGl, Iso2D et IsoGl, ok, mais on oublie Sprite2D et SpriteGl qui héritent de SpriteElement, il faut commencer par le commencement. C’est donc une refonte jusqu’à Element pour répartir les morceaux des différentes classes Sprite*. Ainsi disparaissent Sprite2DElement et SpriteGlElement au profit d’une nouvelle classe SpriteElement toute équipée.

Quand on parle de fusion on parle bien entendu de gérer le contexte de rendu au sein même de la classe. Ceci peut paraître étrange et contre certains bons principes, mais ces morceaux de code partagent parfois jusqu’à 90% du code, ce qui va contre le principe DRY (Don’t Repeat Yourself) et comme j’ai envie de bisous (KISS : Keep It Stupid Simple), j’ai tout regroupé et cela ne m’a demandé que quelque if peu coûteux, ce qui est très important car on appelle ces bouts de code des centaines, des milliers de fois par seconde (selon complexité de votre projet).

Fusion de Sprite*Element

J’en profite pour illustrer le service de rendu Gl et montrer à partir d’où on le connecte. J’en affiche un peu plus mais ainsi on voit les 2 types de regroupement GridElement et IsoElement. Ne prêtez pas attention à GridBlock, vous le connaissez déjà.

Nous sommes bien parti, continuons. On crée une nouvelle classe GridElement et on met ce que contient Grid2D et GridGl, hop tout dedans et on essaye de faire coller les morceaux. Là où ça se corse c’est de bien segmenter les parties communes et spécifiques, puis quand on arrive à IsoElement c’est encore pire car nous sommes basé sur l’héritage donc on ne réécrit que ce qui a besoin de l’être et là on observe des couacs, des oublis pour la 2D vu qu’on s’est concentré sur Gl depuis les lumières. Par exemple la table en 2D ne fonctionne pas, juste en Gl.

Fusion terminée

Sur papier ça parait plus beau, naturel, élégant, [mettre ici tous les beaux mots que vous désirez]… Mais dans la pratique ça demande pas mal d’efforts, de compréhension, évidemment c’est pour un mieux !

C’est quand même un impact de 33 fichiers dans le projet et son POC de démo, 8 dans TARS même, ainsi que 8 suppressions et 2 ajouts. Ça c’est pour les fichiers, mais en terme de lignes de code, même si je n’ai pas de compteur à cet instant, on a effectué une réduction notable, donc plus facile à maintenir, de par le regroupement aussi.

Preuve que ça fonctionne toujours, même si la table n’est pas gérée encore correctement en 2D.

Garder 2D et Gl ?

Pourquoi garder les 2 ? Car il est très difficile de débuger en Gl, vous ne pouvez pas dessiner aisément un repère ou une trace sans sortir les chars d’assaut et beaucoup d’heures de dev alors qu’en 2D vous êtes libre de manipuler le rendu en direct et ce rapidement.

C’est pour cela que le POC (démo) n’utilise plus les lumières en 2D, le but pour moi ici étant un debug rapide sans ajouter des soucis de performances, qui plus est, connus.

De plus, maintenant que la fusion est faite, on pourrait revoir tout le workflow d’utilisation pour ne plus devoir faire (à l’usage) une scène 2D ou une scène Gl. ainsi en changeant juste le mode, toutes les classes personnelles seraient utilisables, ce qui serait un chouette gain de temps et d’effort. De plus les 2 fonctions ajoutées is2DContext/isGlContext permettent d’agir spécifiquement si besoin était.

Et le fameux ensuite ?

Ah ben oui, ensuite quoi ? Corriger le pourquoi de la table en 2D et tenter de régler un conflit Grid/Iso sur un calcul de positionnement.

Il faut absolument faire un POC purement Grid et non Iso, genre un Mario (S)NES ou Duke Nukem 1-2, un truc tout carré pour voir que les calculs sont bons, juste une grille décorée, pas plus.

Ensuite, mes fameuses particules lumineuses et le miroir :p !

Mais bon, comme d’hab on verra ce qui me stitch comme on dit. Je vais déjà de ce pas fusionner les branches du repo et repartir sur une base saine :).

Edit

Pour ne pas refaire un article juste pour ça, j’ai également fusionné les classes du POC (treasure, door, player), leur code était identique. 3 classes de moins sur les 6 initiales (3x2D et 3xGl). Une bonne chose de faites qui va nous simplifier la vie ultérieurement. Les scènes restent dissociées car différentes, la Gl, plus complète, gère la lumière par exemple, les fusionner ne donnerait rien d’intéressant une fois en release. Au moins le debug (2D) peut se faire sans gêner le résultat final (Gl). Nous voilà dans un état propre, quelques corrections de bugs ou de manques seraient à faire pour solidifier avant d’avancer plus.

L’enfer est pavé de bonnes intentions

Notre premier projet étant fait, et le second ayant des besoins commun, j’ai voulu créer ma première librairie et ainsi la partager entre mes projets comme le fait NPM si je publiais. Évidemment je suis sur un git privé, pas sur Github, ni sur NPM, du coup pour le déploiement etc. ben, c’est toute une histoire…

Donc on part d’un code fonctionnel inclus dans un folder de mon app, on crée un nouveau projet de type librairie et on colle ça dedans, on nettoie le premier projet et on fait le lien entre les libs, on compile l’un et l’autre, l’impatience est à son comble ! Et ça ne va pas…

Donc on part d’une lib de référence (Material Design) et on regarde comment ils font, on s’en inspire, on prend les conventions et on observe les liens, on compile etc. Le stress est palpable ! Et ça ne va pas…

Là, le doute s’installe, malgré les lectures officielles, des articles, des mails, un stackoverflow, un appel à l’aide général et de la patience… rien n’y fit. Et la raison reste un mystère.

Que ça soit l’inclusion d’une lib en local, ou via son git perso, que ça soit un module qui ne va pas alors que l’autre oui, la folie s’installe… et surtout le rejet de ce méchant projet, ce qui le met en péril de progression.

Après avoir accepté l’échec de cette noble tentative et un gros FUCK à Angular et leur système « y a qu’les hypsters qui croivent » (comprendre : une élite peu nombreuse et souvent prétentieuse). Ô rage et désespoir… Enfin donc une idée surgit de la bonne vieille école des barbus, à l’ancienne mais restons propre.

Donc on part d’un code fonctionnel inclus dans un folder de mon app et on crée un répertoire vide en dehors et on tape le code dedans sans lui faire de mal, on ajoute un index.ts et on check que tous les liens de notre point d’entrée sont bons et accessibles.

On adapte notre projet avec le tsconfig.json et le package.json. On ajoute un script de post installation et on peut résumer ainsi : je git clone mon projet ou un nouveau et je fais le npm install, il rapatrie les node_modules nécessaires, ça on connait, il lance tout seul comme un grand le script de postinstall et va cloner notre « lib maison » sous forme de répertoire à l’intérieur de notre projet (ajouté au .gitignore), ensuite grâce aux modif du tsconfig.json, il suffit de faire une référence unique vers cette lib « comme » si c’était une lib NPM.

On respecte donc l’énoncée :

  • Chaque projet doit avoir son repo git
  • La « librairie » est facilement maintenable
  • Les projets utilisent la « librairie » facilement
  • L’ensemble ne se gêne pas

Dans le détail, dans package.json :

"postinstall": "git clone ssh://user@server.net/blabla.git src/mon-folder" 

Ajouter votre répertoire au .gitignore de votre projet :

#mon folder
/src/mon-folder

Vient ensuite votre tsconfig.json :

"baseUrl": ".",
...
"paths": {
  "@ma-lib/*": ["src/mon-folder/*"]
}

Enfin, l’usage dans votre app.module.ts :

import { MonModule } from '@ma-lib/index'; 

Un mois… ça aura pris un mois, mais on peut enfin continuer…

Documentation d’API avec RAML

Toujours dans l’optique de documenter le plus proprement et intelligemment possible le projet Folkopedia, j’ai voulu m’occuper de la définition de l’API concernant Schema Definer.

Tout d’abord j’ai pensé un Google Sheet/Doc, mais, bien que fonctionnel, ce n’est pas le plus sexy ni adapté en terme de documentation.

Il existe des solutions comme ApiDoc, Swager, etc. et RAML sur lequel je me suis arrêté. L’idée n’est pas d’avoir un générateur d’API mais un moyen de documenter. Donc un fichier structurant me permettant d’avoir une sortie sous forme de documentation, ici un HTML dynamique.

Comme si c’était notre credo, la documentation stricte du site de base raml.org n’est pas tout à fait bonne, peut-être une confusion entre la v0.8 et la 1.0. Du coup, belotte et rebelotte, vous aurez à croiser quelques liens.

https://github.com/raml-org/raml-examples/blob/master/defining-examples/organisation-api.raml

Dans mon cas je suis sous Visual Studio Code pour éditer mon fichier raml. Il y a l’extension RAML 3.0.1 de blzjns nécessitant raml2html à installer en console (besoin de node) via la commande :

npm i -g raml2html

Pour ma part le preview interne ne donne pas un résultat idéal, je vous conseil donc de compiler vous même votre HTML avec la commande :

raml2html api.raml > api.html

Du coup j’en profite pour vous montrer un exemple fonctionnel et pas fini.

#%RAML 1.0
title: Folkopedia API
baseUri: http://api.folkopedia.com/{version}
mediaType: application/json

types:
  Auth:
    properties:
      login: string
      code: string
  Error:
    properties: 
      code: string
      message: string
  User:
    properties:
      firstname: string[]
      lastname: string[]
      nickname: string[]

/auth:
  post:
    description: Authenticate a user
    body:
      application/json:
        type: Auth
        example: { login: "contact@folkopedia.com", code: "myPassw0rd" }
    responses: 
      201:
        body:
          application/json:
            type: object
            properties:
              token: string
              identity: User
              acl: string[]
            example: |
              {
                token: "auth_token",
                identity: {
                  firstname: ["Jehan"],
                  lastname: ["Bihin"],
                  nickname: ["Killan", "Pôlebreak"]
                },
                acl: ["acl1", "acl2", "acl3"]
              }

Ce qui une fois compilé nous donnera ce type de rendu plutôt sympa.

Oui c’est en UTF-8 mais ça n’a pas bien géré le ô :s

Tout doucement une solution complète se met en place et ce n’est pas faute de l’avoir simplifié à la base. Tant mieux ça fait de l’expérience ! Et de belles prises de têtes en tête à tête ^^. Y a du boulot…

Folk·o·pedia top départ

Suite de l’article précédent sur le même sujet, mais cette fois de manière plus technique. Nous voilà déjà 7 mois plus tard… Rien n’a été fait si ce n’est ce mois de Mai, du coup décortiquons.

Comme dit précédemment, c’est un gros projet, ambitieux et novateurs à différentes échelles, et donc accompagnés de complexités et difficultés pour le démarrer/réaliser. Oui OK je me cherche quelques excuses mais elles sont véritables.

« Mais tout vient à point à qui sait attendre » un sage aurait-il dit; et c’est effectivement le cas : l’illumination du premier jalon. Je vais donc vous parler de ce qui porte le nom, actuellement, de Schema Definer ou le définisseur de schéma.

« Mais qu’est-ce donc ? » demande la foule tel un 42 en réponse d’une longue attente. Tout simplement notre première étape, car vous n’imaginiez quand même pas que le site pouvait se gérer sans structure ?

Aussi permissif le site, en objectif, sera; autant il lui faut une mécanique de définitions pour vous proposer celle-ci. Quelle belle phrase.

Récapitulons après ce blabla. Jusque là nous avons défini un schéma de base de données, donc un moyen de stocker de l’informations organisées selon une définition. Cette dernière décrit un ensemble de tables représentant notre infinie possibilité à décrire tout système folklorique.

Ça c’était il y a +2 ans, selon les idées sur les ontologies. Ensuite j’ai cherché le stack ultime du web de demain… Ok sans succès, on va partir sur un Angular/Lumen pour débuter. Mais je ne désespère pas de trouver le design du web de demain quand on attaquera le front (partie visible et utilisable).

Déjà le fait d’avoir simplifié et décidé du stack, ça soulage, beaucoup même. Je vous recommande Homestead via Vagrant pour démarrer avec simplicité.

Revenons à nos moutons après cette digression. On a de quoi stocker de l’information brute qui n’aura du sens que si on lui en donne, c’est le rôle du Schema Definer. Le schéma sémantique sur le schéma structurelle (stockage).

Si je stock 36, rien ne me dit que c’est mon age, sauf si une définition l’indique au système.

Là je vous ai perdu, entre logique effrayante et explications à rallonge.

Comme dit nous avons un système de stockage différent de ce que l’on a habituellement (relationnel, non relationnel structuré, …) où la conception contient le sens des données. Mais nous, non. Notre mécanique retiendra des nombres et chaînes de textes reliées entre elles, de manière relationnelle.

OK, c’est quoi ce schéma ? Tout simplement la définition de ce que l’on veut encoder sur Folkopedia : une personne, un groupe, un document, …

Pour faire simple, en expliquant sommairement la mécanique triplet RDF, vous aurez un objet/entité (virtuel), qui a des propriétés avec des valeurs.

De manière appliquée : je suis l’instance d’une Personne, ayant pour surnom : Killan. Le schéma précise qu’il existe une entité Personne, avec pour propriété possible un surnom qui sera de valeur texte.

La valeur pourrait être la référence d’une autre entité. Genre une entité livre peut avoir une propriété auteur qui a comme valeur la référence d’une entité personne. Etc.

Avec une table de 3 colonnes vous avez votre système, simple mais horriblement pas performant. Et on ne va pas entrer dans ces histoires-là ici. Le schéma Folkopedia est différent tout en suivant le principe et un futur système de cache fera le delta de performances. On reviendra dessus bien plus tard.

Pour revenir sur le Schema Definer, vous pouvez prendre exemple sur le site de schema.org qui est le premier objectif. Vous pouvez prendre l’entité Book (livre) comme exemple.

Pour résumer, on peut dire que le Schema Definer est une administration de la structure de données du site Folkopedia.

C’est pour ça que j’ai parlé d’Angular, il nous faut une interface pour manipuler ce schéma. Peut-être qu’un jour on exposera ce site, comme explication de la démarche scientifique, on verra, rien n’est figé actuellement. La démarche m’importe beaucoup.

Bref, là on a un Lumen installé sur un Vagrant/Homestead, tout frais, et cet articles un point de départ publique. C’est parti !

L’obscurité naît de la lumière

Le rendu actuel est bien joli, mais ça manque d’effet de lumière et donc d’obscurité. Du coup j’ai tenté l’expérience, pleuré, cassé, et réussi à avoir un premier résultat.

D’abord c’est quoi cette notion de lumière/obscurité ? C’est tout simplement le fait d’avoir une image lumineuse ou assombrie sur base d’une source supposée de lumière et d’un calcul pour définir l’effet.

Une source ? N’importe quel Element peut être une source de lumière, j’ai créé un interface Illuminate pour qu’il se prenne pour un truc radioactif.

Un calcul ? J’ai déclaré,pour le test, que la lumière allait à 6 cases de distance en décroissant de 20% de manière arbitraire. Cependant j’ai prévu une interface (pas finie) dont le but sera de définir des paramètres pour l’éclairage, permettant ainsi des variables d’affichage (couleur, force, …).

Il s’agit donc de définir pour chaque Element de chaque Zones de tous les GridBlocks du subsetMap une valeur de « Brightness » (luminosité) sur base du calcul et des sources. Le tout sans faire de calculs inutiles.

J’ai opté pour le moment de la création du subsetMap pour agir. Ceci arrive uniquement lors d’un changement d’offset de la caméra (déplacement du héro en bord d’écran), on peut alors profiter de la boucle de création pour trouver les lumières de la Scene. Ensuite il faut parcourir à nouveau le subsetMap pour mettre le « Brightness » de tout le monde à 0 : le noir total. Oui deux fois, car nous agissons dans PlayerScene basé sur un IsoScene et la création du subsetMap est dans IsoScene.

Ensuite pour savoir ce que la lumière va affecter je me suis dit que le PathFinder ferait un bon système de propagation, respectant les murs et les objets. Ce n’est pas au point, et cette idée doit être poussée plus loin, mais le départ est pas mal. On imagine une source de 100% qui perd 20% à chaque changement de case, et on ajoute ceci à l’éclairage déjà présent.

La théorie est pas mal, on est en 2D isométrique quand même, on ne peut pas faire trop de folies et on est limité par l’architecture de TARS et des possibilités de « Canvas » (notre support HTML5 de dessin). En pratique : les performances s’effondre…

Mais que se passe-t-il ? Il faut savoir que pour avoir cet effet j’utilise le « filter » « brightness » de « Canvas », en gros une propriété au moment du dessin permettant d’affecter la luminosité de l’image que j’ajout pour composer la Scene. Je le fais déjà avec l’alpha sans soucis (cf titre daaboo). Bref, ça fonctionne mais prend un temps de dingue, ce qui rend le résultat non réactif et donne du mal au navigateur pour répondre.

Il n’y a pas d’autre façon, l’accès au données de l’image n’est permis qu’au niveau du rendu final et non à l’image que l’on va ajouter et on ne peut pas travailler sur l’image stockée en mémoire, d’une part car c’est pas permis et de l’autre car il nous faut l’original pour modifier les effets.

Une seule solution : la protestation ! À chanter comme une manif de métallo un jour de pont entre deux grèves…

Comment améliorer les performances ? Comment faire quand les outils manquent, les accès limités, les possibilités réduites… ? Il y a bien quelques solutions, mais l’idée n’est pas de demander plus de travail aux utilisateurs de TARS, enfin, le moins possible.

Je suis parti sur l’idée d’un cache, le soucis étant le temps de rendu, et on sait que les mises à jour ne sont que ponctuelles par comparaison. Mais comment ? Nous allons créer un « Canvas » par IsoElement (on va surement le descendre en SpriteElement par la suite (c’est fait :p) et on fait le rendu dans ce « Canvas » invisible. Au moment de composer la scène on copie le contenu à sa destination comme on le faisait avant, et on ne fait plus que ça jusqu’à ce que l’on doive régénérer le cache.

Pour vérifier que ça fonctionne bien avant de péter tous mes calculs, j’ai simulé un rendu dans mon cache, un carré rouge et au lieu de dessiner comme l’image ci-avant, on utilise le contenu du cache.

Je vous épargne le retour du bug de hauteur, le curseur qui fait bouger les Elements, les soucis liés à la division du calcul en 2 étapes, etc. On a déjà connu, déjà fixé, et j’ai dû le refaire encore une fois…

Une anecdote cependant, si vous ne dite pas à votre cache de se régénérer sur base des paramètres de votre Element (PlayerElement, TreasureElement, etc.), il restera tel quel depuis son premier rendu, en gros Squellettore ne marchait plus, il glissait, droit comme un pique sans changer d’orientation. Logique si vous avez lu. Du coup on doit penser à invalider le cache lors de nos mises à jour.

Vu qu’on parle d’animation, il faudra permettre de savoir quand une animation change afin de régénérer le cache à point nommé. Et hop un TODO de plus…

Une fois tout cela fait, on a un rendu avec des performances bien meilleures, cela demande un peu plus de manipulations car il faut penser au cache mais globalement c’est une nette différence.

Concernant le cache, certains automatismes pourront être mis en place en interne au lieu de demander à l’utilisateur de TARS d’y penser. Comme invalider le cache si vous changez d’orientation, l’alpha ou la luminosité. Ce qui est fait en même temps qu’écrire cet article :p et ajouter 3 TODOs… comme si j’en avais pas assez… ^^

Revenons à nos lumières. On a un rendu correct sur base du calcul déclaré plus avant, simple, efficace, même si pas fini (murs opposés, murs d’une case supérieure à la distance prévue, …). Du coup je décide d’en ajouter une seconde pour voir si le cumul fonctionne bien et c’est la fête du slip… Je n’ai pas fait de photo, mais en gros le point d’origine est assombri et génère de l’obscurité, les cases éclairées sont plus loin, bref ça ne va pas.

Mais pourquoi ? C’est une sacrée boucle à investiguer. Ce n’est pas la fonction d’action sur les Elements, ce n’est pas les préparatifs, ni les accès aux variables. C’est le propagateur (par extension le Pathfinder). En fait, par un malheureux hasard, il se peut que vous passiez plusieurs fois sur la même case au cours d’une boucle de propagation (ce qui peut arriver). Le truc c’est que je pensais que je les avais bien exclues, et c’est vrai, mais il faut aussi vérifier en préventif.

Mes tests montraient une quantité astronomique de calculs pour ma petite zone de 10×10, ce qui ne me semblait pas vraisemblable. C’est ce qui m’a mit la puce à l’oreille en gros. Un « If » de vérification et d’un coup la folie s’arrête, les lumières apparaissent et se cumulent proprement. En plus dans l’histoire on a augmenté les performances du Pathfinder et du propagateur pour les lumières !


Voici un résultat avec 2 lumières, le coffre statique et le héro mobile.

Il ne faut pas perdre de vue que ce n’est pas fini. Il y a un gros soucis de performance à la mise en place des lumières quand on s’occupe du subsetMap. Il faut considérer les lumières animées et s’occuper également des occlusions (ça serait top) et de l’orientations des objets, par exemple les murs, opposés ou de la case d’à côté nous tournant le dos. Là il va falloir creuser les théories les meilleures pour augmenter la qualité de l’effet sans défoncer les performances.

Une autre chose aussi, vu que nous travaillons en 2D isométrique, serait d’ombrer selon le caractère cubique de nos objets et de prévoir ainsi, soit une image South, une image East et pourquoi pas une image Top pour ombrer selon des calculs d’angle (les normales) et de distance. Le tout serait de fournir à ces objets des informations plus complète qu’actuellement.

Ceci dit, en l’état, c’est déjà chouette comme rendu !

Angular library – how to – snippet

Suite à mon article précédent j’ai souhaité investiguer, attiré par la curiosité maladive et la difficulté jusqu’à lors de compréhension de cette mécanique. Je vous liste ci dessous quelques articles qui m’ont guidé tant pour démarrer que pour me mélanger les pinceaux.

Pour résumer, si comme moi, vous êtes perdu voici un snippet de démarrage et une copie d’un conseil donné dans le dernier lien.

ng new tars --directory . --create-application=false
ng generate library tars-lib --prefix=tars
ng generate application tars-test

La dernière ligne est utile plus tard quand il faudra tester via une app angular la librairie que l’on souhaite publier.

À noter que mon but n’est pas de passer par NPM pour la publication ni d’aller sur GitHub pour distribuer mon code.

Le conseil du dernier article proposait les lignes ci-dessous dans votre package.json (le premier en root) dans l’espace des scripts.

    "build_lib": "ng build tars-lib",
    "npm_pack": "cd dist/tars-lib && npm pack",
    "package": "npm run build_lib && npm run npm_pack",

Ma prochaine étape est le déplacement du code TARS au sein de l’espace tars-lib créé et de refaire les liens vers le fichier public_api.ts. Ensuite de créer, comme actuellement, une app démo/test, sans inclure l’éditeur, juste une scène et un rendu fonctionnel, simple et expressif.

Une fois validé, je pense que l’on peut alors faire le build de la version de la librairie TARS et créé un nouveau projet dédié pour HeroQuest et là, voir si l’éditeur et la base du jeu prenne bien la lib’.

L’amour mathématique

Cette réflexion a pour origine le fait trop fréquent d’indécisions concernant l’affection envers une personne.

Concrètement le « je ne sais plus si je l’aime » ou le « tu l’aimes ? je ne sais pas » est un simple manque de logique.

Partons du principe que notre affection envers une personne est une valeur parmi une jauge, théorie que j’expliquerai peut-être plus tard.

Partant d’une jauge n’ayant qu’une graduation, plus vous rencontrez de personne plus votre jauge en aura. Car à chaque rencontre vous définirez une valeur pour cette personne.

Comment est définie cette valeur, là est la question qui nous permet de répondre à la question initiale de l’affection.

Dans un premier temps vous rencontrez la personne, elle va vous plaire ou non pour x raisons (autres jauges). Ensuite, peut-être que vous allez vous y intéresser et creuser la personne. Vous mettrez continuellement à jour vos jauges.

Jusque là nous avons une kyrielle d’informations sur la personne ainsi que des jauges ayant chacune leur valeur.

La somme de ces jauges définit la valeur d’affection. Plusieurs personne peuvent avoir la même valeur finale en ayant des valeurs de jauges différentes.

A cela on peut parler de goûts et donc d’un coefficient donnés par jauges ce qui va rendre les individus différent dans leur calcul de jauge. ainsi 2 personnes connaissant le même groupe d’autres personnes auront différentes valeurs affective à leur égards.

Pour répondre à la question il suffit de mettre à jour ses jauges, d’additionner et de prendre la décision qui s’impose.

Prenons le cas concret ou le partenaire d’un couple a une valeur affective qui diminue pour l’autre. Une partie des autres valeurs présentent au même instant peuvent passer au dessus. Il sera clairement question que notre individu est plus intéressé par cette autre personne que par l’initiale.

La raison dès lors sera trouvée parmi les jauges additionnées et ainsi il pourra expliquer son changement. Évidemment, selon l’individu il l’exprimera à sa façons…

Suite à cette première réflexion, une machine, une I.A. pour être précis, pourra selon exactement les même principes, mais avec moins de jauges, aimer une personne/I.A..

Comme dit, la seule différence réside dans la quantité de jauges. Même chez les humains, tous les individus n’ont pas le même nombre de jauges. Certains poussent leur réflexions, d’autres se contente de vivre.

Enfin, selon le principe expliqué précédemment que l’ont peut avoir une même valeur finale à plusieurs personnes, on peut donc aimer plusieurs personnes. Nos m?urs, notre éducation, etc. nous disent que l’on ne peut aimer qu’une personne mais dans la logique présente il ne sera que question de choix parmi les personnes ayant la valeur la plus hautes.

Le choix, toujours le choix, peut-être la partie la plus dure 🙂

Évidemment, avec la difficulté du choix, parfois on descend la sélection à la valeur suivante, et ainsi de suite. Mais là on parle du genre humain et de ses incohérences. 😉