NoCV

J'ai été consultant en SSII pendant presque huit ans. J'ai appris l'exercice de la présentation de parcours : adapter le contenu, cadencer le rythme, être lisible, répondre aux questions qui n'ont pas encore été posées, équilibrer le coté technique et le coté humain... rassurer le client. J'avais de bons résultats.

J'ai ensuite travaillé comme responsable technique de projet et architecte dans l'aéronautique pendant quatre ans. Je recevais des CV sélectionnés selon des mots clefs, des rangs d'écoles, ... Je les lisais sans savoir quoi y chercher et sans y trouver grand chose. A l'entretien technique, que la réponse ait été ou pas dans le CV, ce que j'essayais de savoir n'était jamais quelles quantité de tel langage la personne avait écrit, ni quelle version de telle librairie ou tel framework elle connaissait, mais si la personne savait dire "je ne sais pas", si elle arrivait à comprendre mes explications1, si elle était plus intéressée par comment faire les choses ou par pourquoi les faire comme ça... Toutes ces choses qui font que justement, les informations que j'avais avant l'entretient n'étaient pas très importantes. Je n'ai pas vraiment trouvé à l'époque le format d'entretien que j'aurais voulu. Avec le recul, j'aurais pu me contenter de raconter le projet aux personnes et de leur demander ce qu'elles auraient voulu essayer d'y apporter et ce qu'elles espéraient qu'on sache leur apporter...

J'ai appris plus tard à comprendre ce que j'avais perdu malgré mes efforts dans cette période de ma vie, puis à retrouver petit à petit le plaisir que j'avais eu à programmer quand j'avais dix, quinze ou vingt ans2. Je suis devenu freelance et ai commencé à beaucoup moins "travailler" - si travailler veut dire "facturer son temps" - et à beaucoup plus "faire des trucs". Enfin à essayer.

Je me suis demandé, beaucoup, comment faire part de tout ceci dans un CV ; comment y raconter qui je suis, comment laisser le lecteur décider de ce qu'il pense que je pourrais ou pas apporter à son projet et/ou à son équipe. Et puis j'ai admis que ca ne tiendrait pas sur une page A4 en trois parties avec les mot-clés nécessaires. J'ai essayé d'autres choses 3. J'ai fais ce site un peu pour ceci, beaucoup pour écrire tout un tat de choses que je voudrais ne pas oublier de mon parcours.

Il faudra forcément un peu plus de temps pour le consulter que pour screener un CV : si vous vouliez vraiment regarder les vidéos, lire le code... il faudrait probablement un après-midi entier. Mais ce n'est pas l'idée, je voudrais plutôt vous inviter à trouver et à vous attarder sur ce qui pourrait avoir de la valeur pour vous.

Si je devais synthétiser de quelle façon je voudrais contribuer à une équipe, ce pourrait être ça : "Inviter à prendre le temps de mieux faire ce qui pourrait vraiment avoir de la valeur pour nous."


1

J'ai, je crois, beaucoup progressé en explications depuis. Je l'espère en tout cas.

2

J'en ai bientôt quarante-quatre.

3

Ce tweet reste d'actualité, mais je mettrais Rust en têtes des choses que je voudrais pratiquer. (Et Bruxelles serait remplacé par l'est de la Belgique ou Luxembourg.)

Prise de parole en publique

Dans le chapitre suivant, je raconte comment j'ai commencé à parler en public vers quarante ans.

Je n'aime pas les success stories pleines de promesses - en bonne parties dûes aux hasards et à des avantages pas forcément visibles - non reproductibles. Pourtant en discutant avec beaucoup de "speakers" depuis quelques années, j'ai retrouvé très souvent des éléments dans leur histoire : des personnes qui pensaient qu'elles n'avaient rien à raconter, d'autres qui les ont accueillies, des choses qu'on ne se croyait pas capable de faire et pourtant... des orateurs dont j'adore le travail qui sont pleins de doutes et de trac et n'acceptent qu'à moitié l'idée que ce qu'ils et elles proposent est important pour d'autres.

J'ai essayé de trouver des formats d'ateliers qui aideraient des personnes intéressées à se mettre à parler en public. J'ai donné quelques coups de pouce, quelques conseils, j'ai essayé retransmettre ceux qui m'avaient aidé. Mes ces conseils ne touchent que celles ou ceux qui en font la demande, c'est à dire qui ont déjà fait une bonne partie du chemin, et qui savent que je suis disponible pour les y aider, et pour qui je pourrais être la bonne personne.

J'ai donc essayé, en attendant que les unconferences se généralisent, d'autres choses pour atténuer la barrière imaginaire et bien présente entre les orateurs et les participants.

J'essaie d'en présenter quelques unes ici.

Autointerviews Orateurs

Un jour au hasard d'une conversation sur Slack, quelqu'un qui ne parlait pas dans des conférences me demanda "Ca t'apporte quoi de parler en public ?"

La question m'interpela, à la fois parce que je ne me l'étais jamais posée - je le faisais c'est tout -, et parce qu'elle me rappela les nombreuses questions que je m'étais posées lors de mes premières conférences, quand je mettais les pieds au milieu de tous ces gens qui se connaissaient, avaient l'air de savoir ce qu'il se passait et de trouver ça normal.

Celà m'a donné envie à la fois de poser cette question (et quelques autres), et de rendre les réponses publiques pour désacraliser un peu le "status" de speaker.

Il restait à trouver un format, pour le tester rapidement et pour permettre que chacun.e réponde comme ils ou elles le voulaient. J'ai fais un simple repository github avec un template d'issue - j'avais de bonne chance de toucher en premier des personnes ayant déjà un compte -, j'ai posté sur twitter, ça a parlé au gens, le mot a, j'ai eu plein de réponses dont la plupart m'ont étonnées.

Il y a même eu quelques émules (réponses faites en meetup avant les talks) et utilisation (en ateliers de préparation à la prise de parole) qu'on m'a remontées. Il resterait à trouver à ce projet un endroit pour le rendre plus visible ; pour que les personnes à qui il pourrait servir tombent dessus. En attendant, il est là : https://github.com/FabienTregan/autointerview-orateurs

Podcast qui n'a pas encore de nom

On peut avoir trente ans de programmation derrière soit et ne pas tout connaitre. On peut ne pas oser poser une question, on peut oublier d'en prendre le temps. On peut croire déranger une personne qui n'ose pas prendre la parole d'elle-même où qui ne se sent pas légitime ou encore qui pensent que si elle sait, tout le monde sait déjà.

On peut avoir trouvé des réponses longues, précises, détaillées, ou markettées, mais avoir envie d'autre chose.

J'ai eu envie de tester quelque chose :

  • Une question simple dont on n'a pas forcément la réponse
  • Posée à quelqu'un qu'on a envie d'entendre sur le sujet
  • Une réponse courte et a peu prêt accessible

Sur un podcast qui se veut avant tout collaboratif et communautaire. (voir plus bas pour contribuer)

Les épisodes enregistrés

Les question encore en cours

Contribuer

Je prends les questions, les suggestions de personnes à interroger, les relectures, les avis techniques. En fait il me faudrait même de l'aide pour faire connaitre le projet, lui trouver un nom, faire un site web, le rendre lisible par les lecteurs de podcast, l'indexer, trouver un logo... Il me manque aussi probablement beaucoup de compétences sur le traitement du son et d'autres choses encore que je ne vois même pas. Si quelqu'un veut contribuer, il y a surement de la place pour lui ou elle.

Me contacter.

Questions

La bonne question serait avant tout celle que vous n'avez pas posée, pour laquelle vous n'avez toujours pas de réponse simple ou pour laquelle vous avez entendu une réponse dont vous vous êtes dit après coup que finalement, elle était utile.

Les questions portent sur la définition d'un terme technique ou de différences entre deux termes.

Si tu as d'autres questions à poser, si tu veux poser une question dont tu connais très bien la réponse mais tu penses qu'elle mérite qu'on lui accorde quelques minutes, parlons en.

Dans la question, on essaie de présenter très rapidement la personne à qui on la pose - pas pour justifier de sa pertinence mais pour donner un contexte à l'auditeur. On peut suggérer des éléments de réponse, mais la question doit être ouverte, elle ne sera de toute façon enregistrée qu'après la réponse de façon à pouvoir la modifier pour coller à cette dernière. Il n'y a pas de réponse hors sujet.

Suggérer quelqu'un à qui donner la parole

S'il ya quelqu'un que tu as envie qu'on entendre plus, ou si toi-même tu voudrais t'entrainer à prendre la parole et construire une présentation sur un format court et différé, n'hésite pas à me contacter. On trouvera probablement la bonne question.

Répondre

Si je t'ai soumis une question, c'est que quelqu'un a pensé que tu étais un bonne personne à interroger, et je n'en doute pas.

Les réponses sont très libres. Il n'y a pas de réponses hors sujet, ou pas assez complètes. L'idée est plus de montrer que que telle ou telle personne répond à cette question. Il y aura, j'espère, des questions avec plusieurs réponses. Les questions sont enregistrer après les réponses pour introduire celle-ci au mieux et présenter la personne qui répond d'une façon faite en accord avec elle.

Je peux aider à construire la réponse, faire des retours sur la premières version enregistrée, ou aider à trouver la bonne personne pour accompagner la personne qui répond.

Voici quelques guides pour répondre :

  • Les réponses sont diffusées en audio (avec peut être un transcript dans le futur), on peut trouver quelqu'un pour lire si tu préfère contribuer par écrit.
  • Les réponses sont de préférence relativement anonymes (on donnera bien sûr le compte twitter ou github de la personne qui répond si elle le souhaite, mais on n'est pas là pour montrer qui est super fort ou vend quoi sur tel ou tel sujet)
  • Il y a beaucoup de façon d'être "dev", tout le monde ne sait pas ce qu'est un registre, un DMA, le BDD ou DDD. Même si on s'autorise les réponses très techniques, essayons de s'assurer que tout "dev" comprenne au moins l'idée.
  • La réponse est relativement courte, peut être entre deux et cinq minutes.
  • Il peut y avoir une slide (une imagine d'illustration), mais il est probable que tout le monde ne l'ai pas sous les yeux en écoutant la réponse et il faudrait qu'elle ne soit pas nécessaire à la compréhension.

L'idée est d'avoir une question et une réponse que l'on puisse écouter en marchant tranquillement le matin en allant au travail.

Comment j'ai commencé

J'ai commencé à animer des ateliers par hasard : j'avais participé à un atelier EventStorming lors d'un Agile Open France, je voulais en savoir plus, j'ai proposé au groupe Software Crafters Toulouse une soirée sur le sujet pour en apprendre plus. Une bonne dizaine de personnes était intéressée, mais celles qui sont venues connaissaient EventStorming que de nom. J'ai proposé d'essayer de rejouer l'atelier auquel j'avais participé, et ca a fonctionné, il y a eu de la demande et donc d'autres dates.

J'ai commencé à parler dans des conférences je ne sais pas trop comment : Après avoir participé à une grosse conférence, j'ai eu le sentiment que les sujets n'étaient pas ceux que j'aurais aimé voir traités, qu'on y parlait plus de frameworks bientôt à la mode que de notre métier. De fil en aiguille, aidé par les premiers ateliers que j'avais animés et les rencontres, j'ai voulu essayer de proposer un talk sur les boucles (le for, le while, ...) - sujet qui s'est avéré beaucoup plus riche et connexe que je ne l'avais imaginé. Quand j'ai voulu le proposer, j'ai fait le parcours "à vide" une première fois. Comme c'était sur le site du DevFest local dont j'avais déjà croisé plusieurs des organisateurs et que je voulais leur éviter la lecture d'un formulaire rempli de "qslmdkfjqskldfgjqsdjklf", j'ai faussement proposé de parler d'un sujet que je faisais dans mon atelier et dont je parlais peu ("des portes logiques pneumatiques en bois"). Le parcours test à (presque) blanc fait, j'ai proposé mon vrai sujet. Quelques semaines plus tard, j'apprenais avec incrédulité que c'est le premier sujet qui avait été retenu. J'ai donné ce premier talk entre midi et deux, devant peut être trente ou quarante personnes dont la moitié mangeaient leur sandwich dans une salle où ils étaient interdits.

Une de ces personnes me contacta quelques semaines plus tard, pour me dire que le sujet de ma présentation collerait bien avec le thème d'une conférence qu'il organisait. Un court appel skype plus tard, j'acceptais d'y participer sans avoir vraiment compris qu'il parlait d'une keynote devant un amphi plein à craquer et retransmise dans l'amphi d'à coté. Ca, et les retours que j'ai eu sur le fait que ce que je faisais parlait aux gens, m'ont fait continuer.

Des Portes Logiques Pneumatiques en Bois

Une présentation issue d'un projet personnel, qui parle d'orgues de barbaries, de transistors, d'électronique, mais aussi de rater des choses, de procrastination, de trouver des choses qui marchent pour soi, de baby step, d'artisanat, d'esthétique de la technique...

Captation au BreizhCamp 2018 (youtube)

Conférences

Fichiers de la présentation

Les fichiers sont disponibles sur github.

The OldMan Glitch

The OldMan Glitch est un glitch1 dans la première génération de Pokémon2 sur Gameboy. Dans cette présentation, effectuée entièrement sur un émulateur, je propose de montrer la manipulation tout en expliquant dans le détails ses mécanismes : de parler d'off by one error, d'underflow, de buffer overflow, de payload, d'injection de code et d'assembleur, tout ça en s'amusant à faire déconner Pokémon autant que possible.

Captation au DevFest Toulouse 2018 (youtube)

J'ai donné cette présentation en espérant faire prendre conscience à des développeurs ou développeuses que ne pas complètement abandonner la sécurité est à la fois possible, intéressant et utile. Et qu'un bug, ça peut être beau.

Conférences

Fichiers de la présentation

La liste des outils, les sauvegardes, le script détaillé des manipulations et des explications, ainsi que la description du setup - c'est à dire tout ce qu'il faut3 pour rejouer la présentation chez soi ou la redonner - sont disponibles sur github.


1

Un état non voulu par les développeurs dans lequel on peut mettre de façon temporaire un jeu et qui permet d'obtenir des effets non prévus.

2

Pokémon Rouge et Pokémon Bleu, sortis en 1996 au japon et en 1999 en France.

3

A l'exception de la ROM

Des trucs et des machins qui aident à faire des choses

Cette présentation devait porter à l'origine sur la similitudes de topologie des problèmes que cherchent à résoudre un métier à tisser et ordinateur. C'est la première présentation que j'ai accepté de donner avant de l'avoir entièrement écrite ou réfléchie, et après quatre mois de rechercher et de travail, le sujet a beaucoup évolué.

J'y parle finalement de l'évolution des abaques de la mésopotamie à la machine de Babbage, en essayant montrer les mécanismes et de parler de certaines problématiques traversées pendant cet histoire qui résonnent toujours avec certaines questions que posent aujourd'hui l'informatique et le développement logiciel.

Captation au DevFest Toulouse 2019 (Si vous prenez de le temps de regarder cette présentation, merci de lire aussi ce twitt de complément.)

Je devais y parler aussi de métiers à tisser, mais l'impossibilité de trier parmi tout ce que j'aurais eu envie de raconter de ces quatre mois ne m'en ont pas laissé le temps.

C'est une présentation qui a été très frustrante (tellement peu de temps pour tant de travail et de choses à dire). J'ai fini par me réconcilier avec elle et j'y reviendrais peut être sous forme d'atelier quand les rencontres physiques redeviendront plus faciles.

Conférence

Fichiers de la présentation

Les fichiers de la présentation n'ont pas été publiés, n'hésitez pas à me les demander s'ils vous intéressent.

Notes: demarrage d'un projet rust sur STM32F103 (BluePill)

Les tutoriaux et sessions de live coding donnent souvent l'impression que démarrer un projet avec une technologie ou un environnement qu'on ne connait pas est simple. Et pourtant, tout le monde rame. Des fois pendant des jours.

Si vous voulez voir comment j'ai mis un week-end entier à arriver à faire clignoter une LED en Rust la première fois, et ceci malgré le fais que j'en avait fait clignoter des dizaines en assembleur, en C ou en LUA, sur des Pic, des Atmels, des Cortex, des ESP et même sur un 68000, mes notes sont ici.

Apprendre WASM depuis ses spécifications [EN]

Un livre ou un tutorial sur l'utilisation d'une technologie apporte souvent une façon de faire sans forcément donner les éléments pour developper la sienne. En particulier les détails techniques qui ont conduit à ce choix. Quand ils y sont, c'est souvent pour justifier un choix plus que pour permettre d'en comprendre les limites et les aprioris.

Des fois, on peut aussi apprendre directement à partir de documentation de bas niveau et essayer de comprendre comment en faire quelque chose. Cette démarche fonctionne pour moi dans certains domaines (les microcontrolleurs, dans certaines mesure les langages de programmation).

Pour illustrer cette démarche, j'ai publié (originellement ici) mes premiers pas en WASM.

Why ?

tl;dr : because we can.

  • Tutorials will give you a pre-owned view, and most of the time you won't even get it, you'll rather just learn to repeat pieces of code without developping the understanding to tell wether it is the good way, in your particular case, to do things nor why you do them that way.
  • Learning from specification might help you understand how things work under the hood, and will force you to build your own understanding. Also, it will teach you to read the specification, then you won't hesitate consulting it when appropriate.
  • By taking a simple problem and trying to implement a solution using only the specification, you will feel like you are trying to solve a puzzle, understanding what to do with information, clues, and question you find along the way.
  • You may have fun.

Which specification ?

Web Assembly specification is available at https://webassembly.github.io/spec/

It gives the specification of both the execution environment and the thing that it will execute. Since I want to focus on the later, I will use fireFox (56.0.2 64bit) as en environment, and need access to the specification of the API that will help me run some WebAssembly in it.

It is found at https://developer.mozilla.org/en-US/docs/WebAssembly#API_reference

Take a starting point

Developers should be proud of their traditions and history. After reading https://en.wikipedia.org/wiki/%22Hello,_World!%22_program , we can agree that this is the first thing we want to do.

Let's read the documentation, skipping any part that does not answer to an actual question I have. My first question is "how do I send some webassembly code to the browser" ?

The answer should be in the MDN documentation. The second object in the documentation is WebAssembly.Module and and it is said to contain the "stateless WebAssembly code". Sounds good. Let's see how to get an instance of it. The first parameter of the constructor is : >bufferSource >

A typed array or ArrayBuffer containing the binary code of the .wasm module you want to compile.

Ok, great. Hopefully this ".wasm module" is not tied to browser but is specified by the web assembly thing. Let look at the specification. Use navigator to seach for "module" in the index and we find : https://webassembly.github.io/spec/core/syntax/modules.html >WebAssembly programs are organized into modules, which are the unit of deployment, loading, and compilation. Ok, this is what we are looking for.

Now how to make one ? Just after the first paragph, there is a block of text starting with module ::=. If you are not familiar with the notation, it might be time to read the conventions used in the specification and come back, but for now a basic understanding is enough : a module is made of types, funcs, tables, mems... all of which are mandatory and can have one or zero start.

Since we want to start, let's have a look at this start : >The start component of a module optionally declares the function index of a start function that is automatically invoked when the module is instantiated, after tables and memories have been initialized.

Ok, we are probably in the right place. We know we have to make a module with at least those parts :

  • types vec(functype)
  • funcs vec(func)
  • tables vec(table)
  • mems vec(mem)
  • globals vec(global)
  • elem vec(elem)
  • data vec(data)
  • imports vec(import)
  • exports vec(export)

Building an unpopulated module.

We could now try to make a func, then build a funcs 'vec' (probably a vector ?) from it, and carry on until all the content of a module is ready, or we can go the other way around : start making a module then populate it. Let's try the later.

The modules page of the specification tells me what, from a logical point of view, is inside a module, but not how to build a module and actually get the .wasm file we need to give to the WebAssembly API in the browser.

Looking at the index on the right side, we see two top level chapter which are "Binary Format" and "Text Format". Having a look at the "Text Format", going to the part dedicated to "Modules", it describes, as expected, a text format to describe a module.

But remember what we have in the API: >bufferSource > >A typed array or ArrayBuffer containing the binary code of the .wasm module you want to compile. It does not expect a text representation. Searching for "text" in this page gives no result, so I probably need to either have a look at the binary format, or find a tool to convert from text to binary format (which hopefully should be called an Assembler ?)

The second option seems the abvious one. But finding and installing the assembler won't be fun. I will probably need to compile it and, since I boot my computer under Windows this morning, I will probably need to install a toolchain before I can compile the assembler. It seems that for now at least, I will have more fun looking at the Binary Format.

We will, of course, skip the Conventions sub-chapter, and go directly to the last chapter : "Modules". The short text introduction is interesting : we basically will need to write one "section" for each record of a module, except for the "function" record that is split in two sections. We skip all the Indices part for now, and go to Sections :

Each section consists of:

  • a one-byte section id,
  • the u32 size of the contents, in bytes,
  • the actual contents, whose structure is depended on the section id.

ok, easy. We then have a table of the section Id's, with a 0 Id-ed "custom" section (which we apparently can ignore), and eleven sections. We have nine mandatory records in the module, one having two corresponding sections, plus the opional "start" section. Eleven section. Everything is here, we should soon be able to write some code. Or opcodes. Bytes.

Binary formats often have headers and footers. It is a bit unexpected that we directly ran into the Sections. Looking at the index, we see that the last sub-chapter is about... modules !

The introduction text makes things easy to understand, and using the binary grammar we are told how a module is made from bytes !

It starts with 0x00 0x61 0x73 0x6D 0x01 0x00 0x00 0x00 (module version 1) and have sections that all can be empty (but still present) and no footer.

So we basically may have succeed writing our unpopulated module !

0x00, 0x61, 0x73, 0x6D, // Magic number, indicates this is a WebAssembly module
0x01, 0x00, 0x00, 0x00, // Version 1 of the WebAssembly binary format

Populating the module with empty sections

Since we need all the (non custom) sections to be present once, let's start with the first one : typesec, which contains any quantity of functype. This quantity will be zero for now, making things easyer. clicking on "typesec" we reach it's definition. It has the Id 1 and decodes into a vector of function types : >typesec::=ft∗:section1(vec(functype))⇒ft∗ From the binary grammar in the convention chapter, we have : >x:B denotes the same language as the nonterminal B, but also binds the variable x to the attribute synthesized for B. and : >Productions are written sym::=B1⇒A1 | … | Bn⇒An, where each Ai is the attribute that is synthesized for sym in the given case, usually from attribute variables bound in Bi. What does that mean ? That typesec will be made of section1(vec(functype)) (probably a section1 containing an array of functype) that we will name that we will name ft*, and when this data will be loaded, the result will be ft*. Why some so complicated notation ? Because some of the data before the arrow might be used only to control the creation of the structure / object (e.g. : size, checksum, define nature of data...) but not be part of the output of parsing the data.

We already hade a look at the specification where it defines section1 :

sectionN(B)::=
   N:byte  size:u32  cont:B⇒cont
  |ϵ⇒ϵ

The second alternative means that if B is empty, then sectionN is just empty. Great, nothing to do !

You can see that, if we hade a non-empty section, it would start with N (the section Id), then the size (of cont), then cont. But only cont (after the double arrow) will be produced when parsing the data.

Now all the sections that only contains a vector can be empty, which are they ? All but start, which is optional. So our module should already be valid !

Can we test it ? We should just need to put our byte sequence into a file and load it using the API.

Testing a first module

According to the API, we should be able to instanciate a module from a TypedArray, let's try this in the JS console in FireFox :

var moduleBytes = new Uint8Array([0x00, 0x61, 0x73, 0x6D, 0x01, 0x00, 0x00, 0x00])
var myModule = new WebAssembly.Module(moduleBytes);

It works !! Does it really ? Try changing the version number from 0x01 to 0x02 and see if you still can instantiate a WebAssembly.Module : >CompileError: at offset 8: binary version 0x2 does not match expected version 0x1

:)

Adding a section

By looking at the JS API, it seems that we need to export something in order to be able to call it from the JS side :

WebAssembly.Module.exports()

Given a Module, returns an array containing descriptions of all the declared exports.

Then we probably need to have something in the Export section, defined as this :

exportsec  ::= ex*:section7(vec(export)) ⇒ ex*
export ::= nm:name d:exportdesc  ⇒ {name nm,desc d}
exportdesc ::=
   0x00 x:funcidx  ⇒ func x
  |0x01 x:tableidx ⇒ table x
  |0x02 x:nameidx  ⇒ name x
  |0x03 x:memidx ⇒ memid x

So we want a not empty section, containing a vector of exports, the export seems to be a succession of a name and an exportdesc, which is a function, table, name, or memory ID.

We want our JS to call one of our functions, so we need a funicdx which is a u32 (unsigned 32bit). And digging the specification in /Structure/Modules/Indices, we find : >Definitions are referenced with zero-based indices. Each class of definition has its own index space,

Now we need to understand what is the "index space" for the class funcidx. All I could find for now is : >The index space for functions, tables, memories and globals includes respective imports declared in the same module. The indices of these imports precede the indices of other definitions in the same index space.

If I understand correctly, it means that if I have no import, the function Id will be the (zero based) index of the function in the vector of the function section. Since I have only one function for now, I should be 0.

We'll build the function section later, for now is seems that the exportdesc is rather simple : a 0 (to indicate we export a function), followed by another 0 (index of the function we will write) : 0 0

From this, we should be able to write the export.

The export needs a name (nm), which is probably the name under wich the function will be available in the Instance.prototype.exports object on the JS side, and the exportdesc we just encoded.

Let's see how to encode names in the spec binary format / values / name : >Names are encoded as a vector of bytes containing the Unicode UTF-8 encoding of the name’s code point sequence. >name::=b∗:vec(byte)⇒name

Easy ?

We can decide our function to be named "foo" ([102,111,111] in UTF8), which is hasa length of 3, and voila! only terminal symbols (it mean we no longer have to digg, we can climb up now)

// Export Section
0x07, 0x07, // sectionId 7, 7 bytes
    0x01, // content is vector of size 1
    // export
      // nm:name (which is a vect(byte))
      0x03, // 3 bytes
      102, 111, 111, // UTF8 for "foo"
      // d:exportdesc
      0x0, // it is a function
      0x0, // funcidx of the first function in function section (if we have no import)    

Ok, this might work. But we can not test for now because we do not have the function section but we use a funcidx.

You might have noticed that the u32's have been encoded as a single byte instead of the abvious 4. We'll come back to this later, but the spec in binary format / values / integer tells us that they are encoded in LEB128. All you need to know for now is that u32 smaller than 128 will be encoded in just one byte, saving space.

We can make a simpler exportsec by puting a 0-length vector as the content of the section, dodging the need for the function section for now. I will let you do this as an exercice, but once you add the section to the javascript array we made earlier, you may find this:

var moduleBytes = new Uint8Array([0x00, 0x61, 0x73, 0x6D, 0x01, 0x00, 0x00, 0x00, 0x07, 0x01, 0x00])
var myModule = new WebAssembly.Module(moduleBytes);

This works. If we replace the last 0x01 (size of the section) with 0x02, we get: >CompileError: at offset 10: failed to start export section

So far, so good !

Adding the Type Section

Section7 (function exports) uses a funcidx, hence needs a function section.

Section3 (function section) just contains a vector of typeidx, hence needs type section.

Section1 (type section) contains a vector of functype's. And functype is rather easy now that we got used to the sepecification and its grammar : >functype::=0x60 t∗1:vec(valtype) t∗2:vec(valtype)⇒[t∗1]→[t∗2]

It's a 0x60 followed by two vectors of valtype describing the type of parameters and return type. We need no parameter (vector of size 0, just a 0x00) and will return an i32 (we will just return 42 instead of "Hello, World!" for now). The valtype for i32's is 0x7F, so Section1 should look like this:

// Type Section
0x01, 0x05, // sectionId 1, 5 bytes
  0x01, // vector of size 1, only one function type defined
    0x60, // header for function types
    0x00, // t1, zero-length vector because we need no parameter for foo()
    0x01, 0x7F, // t2, return type is an array of length 1 containg id of i32

The section 1 must be before section 7 in the module, so we can test this :

var moduleBytes = new Uint8Array([
  0x00, 0x61, 0x73, 0x6D, // magic number
  0x01, 0x00, 0x00, 0x00, // binary format version 1

  // Type Section
  0x01, 0x05, // sectionId 1, 5 bytes
    0x01, // vector of size 1, only one function type defined
      0x60, // header for function types
      0x00, // t1, zero-length vector because we need no parameter for foo()
      0x01, 0x7F, // t2, return type is an array of length 1 containg id of i32

  // Export Section
  0x07, 0x07, // sectionId 7, 7 bytes
    0x00 //empty vector as content
])
var myModule = new WebAssembly.Module(moduleBytes)

This runs without error, and is more readable than earlier presentation :)

Adding the Function Section

Now that we have defined the type of the foo() function, we can define the function itself. We now have all the training we need to implement this specification : >funcsec::=x∗:section3(vec(typeidx))⇒x∗

Our implementation might be :

// Function Section
0x03, 0x02, // sectionId 3, 2 bytes
  0x01, 0x00, // vector of size 1, with one typeId equal to 0

Our function seems to be defined, but we still have no code for it... The specification of the function section tells us why: >The locals and body fields of the respective functions are encoded separately in the code section.

We know what we have to do next :)

the Code Section

We know should be able to read the specification of the code section. It's a section with number 10, containing a vector of code.

A code is the size of a func followed by the func, which is a vector of locals followed by an expression.

Local's are u32 values followed by a valtype, but we may not need locals for now. The structure / modules / function part of the docs tells us that : >The locals declare a vector of mutable local variables and their types. These variables are referenced through local indices in the function’s body. The index of the first local is the smallest index not referencing a parameter.

We want to return 42 for now, but it's a constant, we may not need locals yet.

Now we need to write the expression part. It's nice how we just have to clicking on the word "exp" on the documentation to be presented with the list of numeric expressions

We should now read the documentation of every instruction, but we are lucky because the first one is 0x41 and defines an i32 constant. 42 is still smaller than 128, so we once again don't have to look at the LEB128 documentation and can define our constant : 0x41 42. I could not understand if I need some opcode to return the constant, so I'll just try.

puting it all together

We now should be able to update our section7 (export session) with the function Id and the name of the function and the section10 (code)

var moduleBytes = new Uint8Array([
  0x00, 0x61, 0x73, 0x6D, // magic number
  0x01, 0x00, 0x00, 0x00, // binary format version 1

  // Type Section
  0x01, 0x05, // sectionId 1, 5 bytes
    0x01, // vector of size 1, only one function type defined
      0x60, // header for function types
      0x00, // t1, zero-length vector because we need no parameter for foo()
      0x01, 0x7F, // t2, return type is an array of length 1 containg id of i32

  // Function Section
  0x03, 0x02, // sectionId 3, 2 bytes
    0x01, 0x00, // vector of size 1, with one typeId equal to 0

  // Export Section
  0x07, 0x07, // sectionId 7, 7 bytes
    0x01, // content is vector of size 1
      // export
        // nm:name (which is a vect(byte))
        0x03, // 3 bytes
        102, 111, 111, // UTF8 for "foo"
        // d:exportdesc
        0x0, // it is a function
        0x0, // funcidx of the first function in function section (if we have no import)    

  // Code Section
  0x0a, 0x06, // sectionId 10, 6 bytes
    0x01, // vector of 1 code (the implementation for foo() )
      //code
      0x04, // 4 bytes
        // func
          //locals
          0x00, // vector size 0
          //expr
          0x41, 42, // opcode for "const 42"
          0x0b // footer for expr
])
var myModule = new WebAssembly.Module(moduleBytes)

Now that the module is ready, we just need to get a new instance and call the exported function:

var myInstance = new WebAssembly.Instance(myModule, {})
myInstance.exports.foo()

Running this code in the console shows... 42 \o/

What did I learn ?

There were no really hard concept to grab (at least for me, having experience with reading datasheet for microcontrolers). The only mistake I made was to read the documentation a bit casually in the end, missing the 0x0b footer at the end of an expr (I lost about 20 minutes on this :) )

  • I now can navigate rather easily in the specification
  • I can read the specification (the grammar)
  • I have a really better understanding of what is WebAssembly

What is left to do ?

  • I still don't know how to return a variable or a string
  • I only took a look at the binary format, not at the specification of the runtime
  • I have no tryed to get JS objects and use them from WebAssembly to see what kind of hack could be done
  • I have not installed a toolchain
  • Making an Elm or Idris compiler would be fun :)

Atelier Introduction à Rust et à l'Embarqué

Cet atelier est complètement en cours de test et d'écriture. Il devrait être possible de commencer à le tester rapidement en petit groupe avec des personnes intéressées et prêtes à essuyer les plâtres.

Format

Cet atelier durera probablement trois jours. Pendant cette durée, les personnes participant assembleront une petite machine dessinant sur des notes adhésives de 78x78mm dont je ne citerais pas la marque.

Il se déroulera en petits groupes (quatre à cinq participants par animateur, un ou deux animateurs, soit entre trois et douze personnes).

Il contiendra trois éléments distincts :

  • Une partie de présentation des bases nécessaires d'électronique et de Rust pour démarrer, proche d'une formation standard.
  • Des points préparés à l'avance et donnés sur demande sur des sujets spécifiques.
  • Une grande partie (au moins la moitié du temps) de programmation en pair ou en mob, dans laquelle les animateurs n'interviennent que pour débloquer, donner les bons pointeurs, ou proposer les points préparés.

Contenu

  • Présentation des microcontrôleurs, de notions d'électronique, et de la machine.
  • La couche d'abstraction du matériel proposée par Rust
  • Flasher un firmware et debugger un STM32F1031 avec un STLink V2, OpenOCD et GDB
  • Notions de base de programmation en Rust (fonctions, traits, monomorphisation, références, borrow checker / lifetimes, pattern matching, await / asynch )
  • Navigation dans les différentes documentations (Datasheet du microcontrôleur, guide du coeur Cortex, Rust Book, Embedded Rust Book, documentation de l'API des crates utilisés, code source de Rust, et peut être le Rustonomicon)

Le but est que chaque personne participant ait une machine dessinant des formes simples (comme un spirographe) à la fin de la seconde journée, et la possibilité et l'envie de continuer après l'atelier les prolongements imaginés et commencés pendant la dernière journée.

Public visé

Il s'adresse à des personnes sachant déjà programmer, ayant si possible deja quelques années de pratique de Java ou du C, et voulant démarrer un premier projet Rust et/ou voulant acquérir des notions de programmation sur microcontrôleur, par exemple pour mieux travailler avec une équipe embarquer s'ils doivent s'occuper du backend d'un projet IoT.

mdbook avec >GitLab CI

Ce site est écrit en CommonMark, la gestion de version est fait avec Git et le rendu HTML est fait avec mdbook, qui génère un site GitLab Pages static qui est aussi déployé via un FTP chez mon hébergeur.

La description du build est presque entièrement gérée par le fichier .gitLab-ci.yml dans la racine du projet. C'est le fichier qui configure les Jobs exécutés par GitLab CI.

Voici son contenu actuel (disponible comme l'intégralité des sources du site sur GitLab

stages:
    - deploy

pages:
  stage: deploy
  environment:
    name: site public
    url: https://www.tregan.fr
  image: rust:latest
  variables:
    CARGO_HOME: $CI_PROJECT_DIR/cargo
  before_script:
    - export PATH="$PATH:$CARGO_HOME/bin"
    - mdbook --version || cargo install --debug mdbook
    - apt-get update -qy
    - apt-get install -y lftp
  script:
    - mdbook build -d public
    - lftp -e "open $FTP_SERVER; user $FTP_USERNAME $FTP_PASSWORD; mirror -R public/ $FTP_DEST_DIR; bye"
  only:
    - master
  artifacts:
    paths:
      - public
  cache:
    paths:
      - $CARGO_HOME/bin

Voici à quoi servent chaque ligne :

stages:
    - deploy

On définit une liste ordonnée de stages. Un stage permet de regrouper un ensemble de jobs (définis juste après) qui pourront s'exécuter en parallèle. Une fois l'ensemble des jobs d'un stage finis, le stage suivant est traité. Nous n'avons pas de jobs parallélisables et définissons donc un seul stage. On aurait pu sauter cette étape, puisqu'en l'absence de définition il y a une liste par défaut (.pre, build, test, deployet .post).

pages:
  stage: deploy

On définit ensuite un Job appelé pages. Ce nom de job spécial fait que s'il existe un répertoire nomé public et que l'on a définit un artéfact pointant sur ce répertoire, son contenu sera publié sur github pages

  environment:
    name: site public
    url: https://www.tregan.fr

Ce job déploie mon site public, visible à l'adresse https://www.tregan.fr. En donnant un nom et une URL à GitLab CI, celui-ci permettra de voir tous les déploiements qui ont été effectués en se rendant sur la page GitLab du projet, dans opérations -> Environments. L'URL permet de se rendre sur le site en cliquant sur la première icone de la ligne du tableau (Open live environment). D'autres options permettent de préciser à GitLab CI comment démarrer, stopper ou redémarrer un environnement et il devient possible de le faire depuis cette page.

  image: rust:latest

le build sera construit à partir de l'image Docker rust:latest qui fournit un environnement de compilation Rust à jour.

  variables:
    CARGO_HOME: $CI_PROJECT_DIR/cargo

On définit une variable qui donne le nom de chemin de Cargo sur cette image.

  before_script:
    - export PATH="$PATH:$CARGO_HOME/bin"
    - mdbook --version || cargo install --debug mdbook
    - apt-get update -qy
    - apt-get install -y lftp

Avant le build:

  • On ajoute le répertoire de Cargo au Path
  • Si mdbook n'est pas installé, on l'installe avec Cargo.1
  • On installe lftp (qui servira a uploader les fichier sur le serveur FTP du serveur Web, inutile si on déploie uniquement sur GitLab Pages)
  script:
    - mdbook build -d public
    - lftp -e "open $FTP_SERVER; user $FTP_USERNAME $FTP_PASSWORD; mirror -R public/ $FTP_DEST_DIR; bye"

La phase de build elle même :

  • On génère le HTML du livre avec mdbook, en s'assurant de mettre la sortie dans public/
  • On envoie avec LFTP le contenu du répertoire public. Les variables suivantes sont définies dans le projet GitLab, dans settings -> CI/CD -> Variables (expand) -> Add Variable , en s'assurant des les mettres en protected (gestion des droits) et masked (effacement des logs quand c'est possible)
    • FTP_SERVER : Adresse du serveur FTP (e.g. ftp.monsite.fr)
    • FTP_USERNAME : Nom du compte FTP (e.g. webmaster)
    • FTP_PASSWORD : Mot de passe du compte FTP (e.g. a3eRrttyf35WxCvBdRy44fS)
    • FTP_DEST_DIR : Chemin du FTP dans lequel copier les fichiers (e.g. www/)
  only:
    - master

On n'effectue le build que si le code a été poussé sur la branche master

  artifacts:
    paths:
      - public

On précise qu'il y a un livrable constitué du contenu du répertoire public. Cela permettra de récupérer le livrable sur la ligne du pipeline correspondant à l'éxecution du job sur la page GitLab du projet dans CI/CD -> Pipelines, mais aussi ce confirmer qu'on veut une mise en ligne sur GitLab Pages dans le cas spécial du job nommé pages. Les artifacts servent aussi à passer le résultat d'un stage au stage suivant, mais nous ne l'utilisons pas ici.

  cache:
    paths:
      - $CARGO_HOME/bin

Enfin, on précise que le répertoire $CARGO_HOME/bin et son contenu peuvent être conservés d'une execution de job à l'autre. Si c'est le cas, la ligne du before_script mdbook --version || cargo install --debug mdbook verra mdbook --version retourner 0 et n'exécutera pas le cargo install -- debug mdbook


1

On pourrait se content de télécharger une version pour l'architecture utilisée pour éviter de télécharger et compiler mdbook et ses dépendances. Cependant, recompiler assure la compatibilité du script quelle que soit l'architecture, et dans les lignes suivantes nous ferons en sorte que le binaire de mdbook soit mis en cache, ce qui est possible puisque nous sommes dans la phase before_script

Première pull request

Un ami posait une question sur l'utilisation d'un logiciel libre. C'est un logiciel de dessin i(krita)et un des outils affichait des informations sous sa main lorsqu'il l'utilisait avec un une tablette graphique qui fait aussi écran. Il voulait savoir si on pouvait déplacer cet affichage.

Les sources du logiciel étant disponibles librement, je suis allé y voir si je trouvais la réponse dedans. J'en trouvais une fonctionnelle mais pas très pratique.

Mes contributions au logiciel libre sont assez minces. Je me suis entrainé à une époque à faire du rapport de bug, et je conseil l'exercice à tout le monde. J'ai bien donné deux ou trois coup de mains sur l'utilisation mais celà s'arrêtait là. Je vais essayer de voir si j'arrive à proposer un modification du logiciel, et en profiter pour tracer le parcours.

Un point qui risque d'être délicat, c'est que malgré un certaine expérience en programmation, je n'ai quasiement jamais fait de C++ (pas plus de deix heures il y a plus de vingt ans), et meme utilisé de langage sans garbage collector (c'est à diore géré à la main les allocations ou désalocations de mémoire. Je continue bien à faire un peu d'assembleur pour le plaisir, mais en gérant la mémoire uniquement sur la pile ou en allocation statique.)

L'idée étant d'aller voir dans le code source, il fallait d'abord le récupérer. Une recherche rapide de "krita source code" donne directement un repository github dont j'apprends plus tard qu'il n'est pas le bon mais qui a suffit pour commencer.

Premier probleme : par où démarrer la rechercher. Deux grosses options :

  • chercher à comprendre en gros l'organisation du code et descendre jusqu'au bon endroit
  • faire une recherche pour trouver une méthode ou une classe qui a l'air de parler de mon sujet.

Je tente la seconde, et cherche le terme qui était dans la question d'origine ("color picker"). Je ne trouve pas grand chose (un seul résultat, dans un fichie d'entête KisScreenColorSampler.h). Ce ne doit pas être le bon mot. Je vais chercher dans la doc de Krita, et le second résultat me confirme que l'outil en question s'appele le Color Sampler. (C'est la référence à la touche control qui est aussi dans le twitt qui me rassure).

Bref, je retrouve rapidement le pendant de mon header et commence à regarder si le nom d'une des méthodes m'inspire. sampleScreenColor semble pas trop mal, c'est peut etre à ce moment que l'information mal placée est afichée ou mise à jour ? Ca n'a pas l'air. grabScreenColor ? Non plus, même si ca commence à parler de coordonnées. Je vois d'une part des mise à jour de label text (l'outil n'affiche pas de texte, donc a moins que le code de l'outil affiché sur la feuille ne soit mélangé avec ud code de l'affichage des pallettes de couleurs sur le coté, il y a un probleme), d'autre part j'ai clairement trouvé une fonction nommée setCurrentColor mais ni elle, ni le code qui ne l'appele ne semble faire de mise à jour de l'IHM.

Un truc important quand on navigue dans une base de code inconnue, c'est de savoir jusqu'où on pense devoir s'entêter. Là c'est probablement le moment de me dire que j'ai fais fausse route et de vérifier des choses. Le fichier que j'ai ouverts s'appèle KisScreenColorSampler.cpp, est-ce qu'il y a d'autres candidats ? Je refais la recherche dans Github et trouve un kis_tool_colorsampler.cc référencé dans CMakeList.txt, puis un kis_tool_colorsampler.h. En fait il y a un bouton "go to file" dans github plus pratique. Je tape ColorSampler je trouve le bon fichier.

Je reprends le meme genre de scrutage que dans le fichier précédant, et je trouve une méthode KisToolColorSampler::activatePrimaryAction(), qui elle même fait un m_helper.updateCursor(!m_config->sampleMerged, m_config->toForegroundColor);

J'essaie de trouver ce qu'est ce m_helper qui semble être définit ou référencé en début de fichier :

KisToolColorSampler::KisToolColorSampler(KoCanvasBase *canvas)
    : KisTool(canvas, KisCursor::samplerCursor()),
      m_config(new KisToolUtils::ColorSamplerConfig),
      m_helper(dynamic_cast<KisCanvas2*>(canvas))

Je ne comprends pas trop la notation. Pas trop grave, le but là est de trouver où est l'info, et de la comprendre après. Pas de comprendre tout le code. Je cherche ce m_helper dans toute la base de code... avant de comprendre que bien sur, il est défini dans le fichier .h correspondant à mon fichier .cpp. Je trouve son type, et donc dans le fichier correspondant me dit que la methode updateCursor fait peut être un truc louche en utilisant le curseur pour afficher l'info. Je parcours un peu le code, arrive à une réfrence à la classe kis_cursor qui a l'air de fournir les representations de curseurs, et à ce code :

QCursor KisCursor::samplerLayerForegroundCursor()
{
    return load("color-sampler_layer_foreground.xpm", 8, 23);
}

qui fait référence au fichier .xpm que je ne trouve pas dans github... Je clone le repository en local (270Mo!) et la trouve le fichier... qui ne contient qu'un bête curseur de souris sans rien pour afficher la couleur selectionnée au mauvais endroit... fausse route !

Allez, pas grave, on revient un peu plus haut, et dans le SamplerHelper cette fois je trouve quelque chose qui a l'air beaucoup plus prometteur : colorPreviewDocRectImpl.

Je n'ai toujours rien compris au code, qui semble lisible mais sans commentaire pour dire à qui servent les classes, je n'ai pas cherche de document de vue d'ensemble, j'essaie juste de trouver où est le code qui dit ou est affiché cette information mal placée, à partir de quoi j'essaierai de comprendre des choses. Je ne suis pas certain de comprendre la première ligne, mais la deuxième commence à carrément ressembler à ce que je cherche :

    KisConfig cfg(true);
    const QRectF colorPreviewViewRect = cfg.colorPreviewRect();

Il existe un fichier KisConfig.cpp, je reprends ma lecture des signatures. J'apperçois au possage un m_cfg, je suppose que c'est hérité ou importé ailleurs ? Ou alors c'est la variable privée et le KisConfig cfg(true) plus haut déclare une variable cfg en appelant le constructeur de KisConfig ? Bref, j'y connais rien en C++, faudra que je lise un peu de doc...

Et là, dans activateDelayedPreview, les choses commencent à se préciser !

m_d->showPreview = true;

Bon, je commence à m'éparpiller et a essayer de comprendre des choses trop tôt. Revenons à la ligne la seconde ligne, celle qui semblait simple et évidente : const QRectF colorPreviewViewRect = cfg.colorPreviewRect();

Si je cherche ce colorPreviewRect() dans KisConfig.cpp, l'implémentation est courte :

QRect KisConfig::colorPreviewRect() const
{
    return m_cfg.readEntry("colorPreviewRect", QVariant(QRect(32, 32, 48, 48))).toRect();
}

Il semble qu'il aille chercher une valeur nommée colorPreviewRect dans un truc de configuration, et prenne une valeur par defaut s'il ne la trouve pas. Je pourrais aller vérifier cette hypothese en regardant le code de readEntry, mais je pense commencer à être suffisemment proche de la solution pour commencer à avoir envie de comprendre un peu le contexte locale. Je fais une recherche sur internet pour voir où est-ce que Krita sauve sa configuration (j'ai pas trovué de fichier ~/.krita), et découvre le fichier ~/.config/kritarc (et kritadisaplyrc).

J'essai à tout hasard d'ajouter dedans le nom de ma variable de configuration. Apparemment on peut mettre plusieurs valeurs séparées par des virgules, et je tente donc d'ajouter :

colorPreviewRect=100,200,100,200

Je relance Krita et... Victoire ! La preview de la couleur s'affiche n'importe où et avec une taille différente. J'essaie des valeurs négatives, j'arrive à positionner à un endroit ou Kholo arrivera à voir la couleur, je lui donne le truc sur Discord, et c'est deja une première contribution au logiciel libre : j'ai dépanné un utilisateur :)

Dans sa question sur Twitter, Kholo avait pingué l'équipe de Krita. Je donne ma réponse pour voir s'ils la valident ou s'ils ont mieux. J'efface le twitt et le re-redige de façon un peu mieux construite, au cas où ca puisse servir à quelqu'un d'autre :

Looking at the code, I found you can add :
colorPreviewRect=-100,-100,50,50
in ~/.config/kritarc

you probably want to add it as last line of the file, since the last section is [tool_color_sampler]

On discute un peu avec Kholo de l'utilité de ce changement de position, on vérifie qu'on ne trouve pas l'option dans l'IHM, de si c'est un endroit ou ce serait "rentable" pour l'équipe de dev de Krita de mettre des efforts (a mettre dans en balance avec tout le boulot qu'il y a à faire sur le logiciel, même s'il est deja très très utilisable). Comme Kholo m'a dit qu'il avait essayé de lire le code mais avait abandonné finalement très prêt de la solution, et comme j'essaie d'enjoindre les utilisateurs de logiciels libre à apprendre à contribuer en faisant des bugs reports et que j'ai moi meme trouvé l'exercice intéressant... je commence à me dire que je pourrais essayer d'ajouter l'option dans l'IHM.

Contact

  • Twitter: @Twitter (DM ouverts mais le système de filtre de twitter n'est pas optimal)
  • Mail: fabien at tregan point fr

Pour suggérer des corrections ou améliorations sur ce site, une possibilité est d'utiliser le système de pull request ou issues de gitlab. Mais si Twitter ou le Mail sont plus simple pour vous, n'hésitez pas.

Information sur les cookies

Il n'y en a pas.

Le local storage est utilisé pour sauvegarder le thème1 choisi. Cette information reste sur votre navigateur et n'est jamais envoyée au serveur.


1

Cliquer sur le pinceau en haut à gauche des articles.