# Afficher du texte sur 7 pixels de haut > Comment on a recodé un pipeline de rendu typographique complet — FreeType, kerning, scrolling — pour piloter des afficheurs LED 48×7 pixels encastrés dans un baby-foot connecté. Date : 20/03/2014 Auteur : Aurélien N. Tags : Ruby, FreeType, Embarqué, LED, IoT --- Dans un [précédent article](/blog/extension-c-ruby-embarque-i2c), on expliquait comment on a écrit une extension C pour connecter Ruby à l'électronique du baby-foot Tekbak via I2C. Mais le baby ne se contente pas de détecter des buts — il les affiche. Et c'est là que les choses se sont corsées. ## Des afficheurs pas comme les autres Les afficheurs de Tekbak ne sont pas des écrans LCD, ni des matrices LED standard qu'on trouve chez Adafruit. Ce sont des **panneaux LED custom**, fabriqués spécialement pour le baby-foot. Ils sont insérés sur les côtés de la table, sous un plaquage bois, visibles à travers une fente. Les specs : - **48 pixels de large, 7 pixels de haut** — c'est tout - **On/Off uniquement** — pas de niveaux de gris, pas de couleur, chaque pixel est soit allumé soit éteint - **Deux afficheurs** — un de chaque côté de la table (hinge et lock) - **Connexion UART** à 230400 baud via `/dev/ttyO1` et `/dev/ttyO2` Le challenge : le client veut pouvoir afficher des **messages arbitraires** — noms de joueurs, scores, "BUT !", "PROLONGATION", et tout ça dans **plusieurs langues**. Pas question de précalculer des bitmaps. Il faut un vrai rendu typographique, à 7 pixels de haut. ## Le protocole UART La communication avec les afficheurs est brutalement simple. On envoie un buffer de 336 octets (48 × 7) précédé d'un header de 2 octets : ``` [0x02] [0x01] [pixel₀] [pixel₁] ... [pixel₃₃₅] ``` Chaque octet vaut soit `0` (éteint) soit `255` (allumé). Le connecteur Ruby gère ça en quelques lignes : ```ruby class DisplayConnector < Connector def sync_publish(payload, parameters=) if payload.kind_of? Array payload = payload.collect .join end prefix = 2.chr + 1.chr @serial.write(prefix + payload) end end ``` Les afficheurs répondent aussi : un `TAP` quand un joueur touche l'écran (détection capacitive), et `ILU|128` pour le niveau de luminosité ambiante. Ces messages arrivent ligne par ligne via un thread de lecture dédié. ## Recoder la rasterisation Le vrai problème, c'est de transformer une chaîne de caractères en une grille de 48×7 pixels. On ne peut pas utiliser de bibliothèque de rendu HTML ou Cairo — on est sur un BeagleBone avec des contraintes mémoire, et le résultat doit être un simple tableau d'octets. On s'est appuyé sur **FreeType2** via la gem `ft2-ruby` pour accéder aux polices TrueType, mais toute la logique de composition — kerning, baseline, cadrage — a dû être recodée à la main. ### Le pipeline de rendu ### Passe 1 : les métriques Avant de dessiner quoi que ce soit, on doit savoir quelle taille fera le texte rendu. Chaque caractère a son propre `advance` (largeur effective), son `ascend` (hauteur au-dessus de la baseline) et son `descent` (en dessous). Et entre chaque paire de caractères, il peut y avoir du **kerning** — un ajustement horizontal pour que "AV" ou "To" ne semblent pas trop espacés. ```ruby def compute_string_metrics(characters, face) @width = 0 @ascend = 0 @baseline = 0 kerning = 0 previous_character = nil characters.each_with_index do |character, index| if previous_character kerning = face.kerning(previous_character, character)[0] end @ascend = [@ascend, character.ascend].max @baseline = [@baseline, character.descent].max if index == characters.size - 1 @width += character.width # dernier : largeur réelle else @width += character.advance + kerning end previous_character = character end @rows = @ascend + @baseline end ``` Le détail qui tue : pour le **dernier** caractère, on utilise `width` (la largeur du bitmap réel) au lieu de `advance` (qui inclut un espacement pour le caractère suivant). Sans ça, on a un espace vide à droite qui décale le centrage. ### Passe 2 : le rendu pixel par pixel Une fois les métriques calculées, on alloue un buffer et on y copie chaque glyphe : ```ruby def render(characters, face) @buffer = [0] * (width * rows) kerning = 0 previous_character = nil offset_x = 0 characters.each_with_index do |character, index| if previous_character kerning = face.kerning(previous_character, character)[0] end offset_x += kerning offset_y = rows - character.ascend - @baseline copy_buffers(self, character, offset_x, offset_y) offset_x += character.advance previous_character = character end end ``` La fonction `copy_buffers` fait une copie pixel par pixel avec une gestion de transparence minimaliste : si le pixel source vaut 0, on ne l'écrit pas (on ne veut pas écraser un pixel déjà allumé d'un caractère précédent). ### Le chargement des glyphes Chaque `Character` est construit en demandant à FreeType de rasteriser le glyphe à 7 pixels de haut : ```ruby class Character def initialize(character, face) glyph = face.glyph_for_char(@char) bitmap = glyph.bitmap # On duplique TOUT ici car l'objet C de FreeType # est écrasé à chaque appel à load_char... @rows = bitmap.rows @width = bitmap.width @top = glyph.bitmap_top @advance = glyph.advance[0] / 64 @descent = [0, @rows - @top].max @ascend = [0, [@rows, @top].max - @descent].max @buffer = bitmap.buffer.bytes end end ``` Le commentaire dans le code est un piège vécu : la gem `ft2-ruby` réutilise le même slot mémoire C pour chaque glyphe. Si on ne duplique pas les données immédiatement, le caractère précédent est silencieusement écrasé. On a mis un moment à comprendre pourquoi tous les caractères se transformaient en "e" (le dernier chargé). ## Le threshold : de grayscale à on/off FreeType produit des bitmaps en niveaux de gris (0-255). Nos afficheurs sont **binaires** — allumé ou éteint, pas d'entre-deux. Le `Buffer` fait la conversion : ```ruby def get_matrix buffer.collect end ``` Le seuil à 127 est un compromis. Trop bas, les caractères deviennent gras et illisibles à cette résolution. Trop haut, on perd des pixels et certaines lettres se cassent. À 7 pixels de haut, chaque pixel compte. ## Le scrolling Un message comme "PROLONGATION" fait bien plus que 48 pixels de large. La solution : le **scrolling horizontal**, piloté par des timers EventMachine. ```ruby def start_animation(side, rendered_text) position = [0, 0] # centrage vertical position[1] = [((screen_height - rendered_text.rows) / 2).to_i, 0].max if rendered_text.width > @screen_buffer.width start_text_scrolling(side, rendered_text, position) else # centrage horizontal + affichage statique position[0] = [((screen_width - rendered_text.width) / 2).to_i, 0].max keep_static_text(side, rendered_text, position) end end ``` Le scroll avance d'**un pixel toutes les 70ms** (environ 14 pixels/seconde). Quand le texte a fini de défiler, on attend 500ms puis on recommence : ```ruby def start_text_scrolling(side, rendered_text, position) @animation_timers[side] = EM::PeriodicTimer.new(0.07) { position[0] -= 1 render_message_step(side, rendered_text, position) if rendered_text.width + position[0] <= @screen_buffer.width @animation_timers[side].cancel @animation_timers[side] = EM::Timer.new(0.5) end } end ``` Pour les textes courts ("4 - 2", "BUT !"), on centre horizontalement et on rafraîchit toutes les 300ms en statique — les afficheurs n'ont pas de mémoire persistante. ## Le système de messages à priorités Le referee ne se contente pas d'envoyer un message et d'attendre. En pleine partie, on peut recevoir un score à afficher *pendant* qu'un flash "BUT !" est encore visible. Le service display gère trois niveaux de priorité : ```ruby @messages = { lock: } ``` À chaque événement, on affiche le message de plus haute priorité disponible : `error > flash > message`. Les flash disparaissent automatiquement après 0.9 seconde, et le message par défaut reprend. ## Les polices Trouver des polices TrueType lisibles à 7 pixels de haut, c'est un problème en soi. On a testé une dizaine de polices pixel-art avant de trouver celles qui marchent. La config finale : ```json { "fonts": , "default_font": "pixelated" } ``` `pixelated.ttf` est la police par défaut — elle a été pensée pour du pixel art et rend bien à très basse résolution. `Minecraftia` est le fallback pour les caractères manquants. Le renderer charge toutes les polices au démarrage via FreeType : ```ruby def register_face(name, file_path) ft_face = FT2::Face.new(file_path) ft_face.set_pixel_sizes(0, @screen_height) # 0 = auto width, 7 = height @faces[name.to_sym] = Face.new(ft_face, @screen_height) end ``` Le `set_pixel_sizes(0, 7)` demande à FreeType de rasteriser les glyphes pour une hauteur de 7 pixels. Le hinting de FreeType fait un travail correct à cette taille, mais certains caractères accentués (é, è, ê) débordent ou perdent leur accent — on a dû ajuster les polices pour le français. ## Le debug en ASCII art Un détail qu'on a beaucoup apprécié : chaque rendu est loggé en ASCII art dans les logs du service : ```ruby def buffer_to_string(source) result = '' source.buffer.each_slice(source.width) do |s| result << s.collect .join('') result << "\n" end result end ``` Ce qui donne dans les logs : ``` rendering message "4 - 2" with font pixelated rendered message: ..#..........#... ..#....###...#... ..#...........#.. #####..###...##.. ..#..........#... ..#....###..#.... ..#.........####. ``` En SSH sur un BeagleBone dans un bar, c'est le seul moyen de débugger ce qui s'affiche. On a beaucoup utilisé ça pour ajuster les polices et le threshold. ## Ce qu'on a appris **FreeType est surpuissant mais bas-niveau.** La lib fait un travail formidable de rasterisation, mais le compositing (kerning, baseline, alignement) est entièrement à notre charge. À 7 pixels de haut, chaque erreur d'un pixel est visible. **La gem `ft2-ruby` a des pièges.** Le slot de glyphe partagé en mémoire C est un classique des bindings Ruby vers du C — l'objet Ruby semble exister mais les données en dessous ont changé. Il faut tout dupliquer immédiatement. **Le threshold binaire est impitoyable.** Pas de dithering, pas d'antialiasing — un pixel est allumé ou éteint. À cette résolution, c'est le choix de la police qui fait 90% du travail, pas le code. **Les timers EventMachine marchent bien pour le scrolling.** On s'attendait à des saccades, mais à 70ms par frame sur un ARM à 1 GHz, c'est fluide. Le fait que tout le service soit single-threaded (reactor pattern) évite les problèmes de concurrence sur les buffers.