Steven
Steven10 min de lecture

Imprimer une réunion en PDF sans bibliothèque PDF

GeekBye exporte une réunion en PDF, et il n'y a aucune bibliothèque PDF nulle part dans le code. Il rend du HTML dans une fenêtre de navigateur invisible et l'imprime. Ce choix est toute l'histoire : il a rendu la fonctionnalité facile à construire et lui a donné toutes les défaillances qu'a un vrai navigateur — un flash blanc, une limite de longueur d'URL, et un saut de page qui coupait les captures en deux. Le correctif du bug le plus laid a tenu en une seule ligne de CSS.

Ingénierie
Electron
Desktop
Versions de GeekBye
Imprimer une réunion en PDF sans bibliothèque PDF

Il y a un tour de passe-passe satisfaisant caché derrière le bouton « exporter cette réunion en PDF » de GeekBye : il n'y a aucune bibliothèque PDF derrière. Pas de jsPDF, pas de pdfmake, pas de pdfkit, pas de puppeteer headless. Toute la fonctionnalité est un navigateur caché. L'app construit une page HTML décrivant la réunion — entrées de transcription, captures, horodatages — la charge dans une fenêtre Electron invisible, et demande à cette fenêtre de s'imprimer elle-même en PDF. webContents.printToPDF fait le reste. Ce seul choix architectural est toute l'histoire de cette version, parce qu'il a rendu la fonctionnalité peu coûteuse à construire et lui a remis chaque bug qu'a un vrai navigateur.

L'avant, c'est la même version

D'habitude ces articles ouvrent sur « voici comment ça marchait avant ». Cette fois il n'y a pas d'avant. Le changelog de la v1.8.9 se lit comme de la maintenance — "fixed page breaks splitting screenshots and filtered empty entries" — comme si l'export PDF était une fonctionnalité existante qu'on rafistolait. Ce n'était pas le cas. Le premier commit qui l'introduit, feat: add PDF export via Electron printToPDF, atterrit trois jours après le début du même cycle de version, et chaque « correctif » du changelog est de l'itération de la même semaine sur du code vieux de quelques heures. La fonctionnalité est née, a cassé, et a été durcie, le tout dans un seul tag. Ça vaut la peine de le dire tout haut parce que c'est la forme honnête de beaucoup de ce qu'on livre : la puce polie des notes de version et la naissance brouillonne de la chose sont fréquemment la même version.

Pourquoi un navigateur caché tout court

Le mécanisme mérite d'être compris avant les bugs, parce que les bugs en descendent tous. Tout vit dans un seul fichier du processus principal, electron/ipcHandlers/pdfExportHandler.ts. Quand le renderer déclenche l'IPC 'pdf:export-report', le handler :

  1. Appelle buildReportHtml() pour assembler une chaîne HTML complète à partir de la timeline de la réunion — un bloc .item par entrée de transcription ou capture.
  2. Écrit ce HTML dans un fichier temporaire dans os.tmpdir().
  3. Charge le fichier dans une fenêtre cachée — new BrowserWindow({ show: false, opacity: 0, focusable: false, skipTaskbar: true }).
  4. Attend que les images se chargent, puis appelle win.webContents.printToPDF({ printBackground: true, margins: { … } }).
  5. Enregistre le résultat dans app.getPath('downloads') sous Meeting-Report-<timestamp>.pdf.

L'attrait est évident. Une bibliothèque PDF vous fait tout positionner à la main — dessiner du texte en (x, y), le mesurer, avancer le curseur, gérer vous-même les sauts de page. Un navigateur fait déjà tout cela : vous écrivez des <div> et du CSS, et le moteur de mise en page de Chromium pagine pour vous. Vous n'apprenez pas une API de dessin ; vous écrivez une page web. Pour un document surtout fait de texte stylé et d'images embarquées, c'est un raccourci énorme.

Le hic, c'est que vous avez aussi hérité d'un navigateur, avec tous ses cas limites. Trois d'entre eux ont surgi dans les quatre premiers jours.

Le flash blanc. Une fenêtre cachée sur macOS n'est pas cachée de façon fiable. Les premières tentatives la positionnaient hors écran (x: screenWidth + 1000), et elle flashait quand même un rectangle blanc à l'écran pendant une image avant l'impression. Le correctif, après trois essais, fut de cesser de se fier à la position ou à show: false et de mettre opacity: 0 — une fenêtre totalement transparente ne flashe pas, même quand le compositeur la montre brièvement. Le commit le dit sans détour : "show:false with off-screen positioning still flashes on macOS."

La limite de longueur d'URL. La première version incrustait tout le HTML — captures comprises — comme une URL data:. Ça marche jusqu'à ce qu'une réunion contienne quelques captures encodées en base64, moment où l'URL file au-delà de la limite de data-URL de ~2 MB de Chromium et où le chargement échoue tout net avec ERR_INVALID_URL. C'est pourquoi l'étape 2 ci-dessus écrit un fichier temporaire et fait loadFile() au lieu de charger une data URL : un chemin de fichier n'a pas de limite de longueur. (Ce ~2 MB est le seul chiffre de toute cette histoire, et c'est une estimation en ligne dans un commentaire de code, pas quelque chose que quelqu'un a mesuré — traitez-le comme « assez grand pour se heurter dedans », pas un seuil précis.)

Le texte non échappé. Le texte de transcription est du contenu utilisateur arbitraire, et il était injecté tel quel dans la chaîne HTML. Toute transcription qui contenait par hasard quelque chose comme <div> ou <script> casserait la page ou pire. Alors le handler a gagné une passe d'escapeHtml() sur chaque chaîne qu'il interpole. Dès l'instant où votre PDF est une page web, l'injection HTML est aussi votre problème.

Les cases vides qui n'étaient pas vides

Maintenant le bug vedette, parce que c'est le meilleur. Les utilisateurs signalaient des PDF exportés avec des cases vides — des entrées qui montraient un horodatage et un badge mais aucune capture, juste du vide là où l'image devrait être. La lecture évidente est « l'export perd les données d'image », et le correctif évident est « filtrer les entrées vides ». L'équipe a bien ajouté ce filtre — on y revient dans une seconde — mais ce n'était pas la vraie cause.

La vraie cause était la pagination. Chaque entrée est un conteneur .item contenant un horodatage, un badge et une image. Chromium, imprimant en PDF, se permettait volontiers de couper une page au milieu d'un item — rendant l'horodatage et le badge au bas d'une page et poussant l'image sur la suivante. Ce qui ressemblait à une case vide était la moitié supérieure d'un item dont l'image avait débordé sur la page d'après. Les données étaient toutes là ; la mise en page les avait guillotinées. Le commit est franc sur le mauvais diagnostic : "The empty boxes in exported PDFs were caused by Chromium's printToPDF splitting .item containers across pages — the time/badge rendered on one page while the image overflowed to the next."

Le correctif est une ligne de CSS :

.item {
  break-inside: avoid;
}

break-inside: avoid dit au moteur de mise en page de garder un item entier — s'il ne tient pas dans l'espace restant d'une page, déplacer l'item entier sur la suivante plutôt que de le couper. Voici le bénéfice de l'architecture navigateur-caché énoncé en miniature : le bug le plus laid et le plus signalé de la fonctionnalité fut corrigé non pas avec de l'arithmétique de hauteur de page et des appels manuels à addPage(), mais avec une seule propriété CSS que le navigateur savait déjà honorer. Vous n'avez pas écrit de paginateur ; vous avez demandé à celui dont vous avez hérité de bien se tenir.

Deux couches de filtrage du vraiment-vide

Il y avait aussi de véritables entrées vides — juste pas celles qui causaient les cases. Des doublons vierges d'un double Cmd+Enter rapide, des captures sans aperçu, des entrées de transcription qui n'étaient que des espaces. Celles-là ont été filtrées, et fait intéressant, elles ont été filtrées deux fois, à deux couches :

  • Dans le handler, buildReportHtml() garde chaque item — if (!item.content?.trim()) return '' pour le texte, et pour les images const safeSrc = item.preview?.startsWith('data:image/') ? item.preview : ''; if (!safeSrc) return '' — puis .filter(Boolean) jette les vides. Ce dernier contrôle fait double emploi : c'est une porte de validité et un petit contrôle de sécurité pour que seules de vraies données d'image atteignent la page.
  • À la source de la timeline, dans le renderer, la même vacuité est filtrée avant même qu'un item soit ajouté — if (!entry.text?.trim()) return, if (!screenshot.preview) return.

Le joli détail dans l'historique, c'est la méthode. Le premier commit a ajouté une boucle logger.debug('PDF timeline item', …) explicitement « pour déboguer les entrées vides ». Une fois que ce logging a révélé d'où venaient réellement les blancs — y compris un bug distinct de déduplication de captures où des captures rapides produisaient des doublons vierges — le commit suivant a corrigé la cause racine en amont et supprimé le logging de debug. Ajoute de l'instrumentation pour trouver la cause, corrige la cause, supprime l'instrumentation. La puce du changelog "filtered empty entries" contient discrètement tout cet arc ajouter-puis-retirer.

v1.8.12 : « excludes AI responses », lisez le diff

Trois versions plus tard, un changement de plus — et c'est une bonne leçon sur le fait de lire le code plutôt que le changelog. La note de la v1.8.12 dit que l'export PDF « excludes AI responses for cleaner meeting records ». Vrai, mais incomplet.

La timeline d'export étiquette chaque item d'un type : 'transcript' | 'chat_user' | 'chat_assistant' | 'screenshot'. Une ligne de réunion parlée est un transcript ; une question que vous avez tapée dans le panneau de l'assistant est un chat_user ; la réponse de l'IA est un chat_assistant. Le changement de la v1.8.12 est une édition de neuf lignes dans un fichier du renderer, et la ligne opérante est un filtre placé juste avant la construction du payload d'export :

timeline.filter((item) => item.type === 'transcript' || item.type === 'screenshot')

C'est une liste d'autorisés, pas une liste de bloqués. Elle ne retire pas les items chat_assistant ; elle ne garde que transcript et screenshot et jette tout le reste — ce qui veut dire qu'elle retire les propres questions chat_user de l'utilisateur en même temps que les réponses de l'IA. Le corps du commit est plus précis que le changelog : "PDF export keeps only transcripts and screenshots, no chat messages." L'enregistrement exporté est maintenant purement la réunion — ce qui a été dit et ce qui était à l'écran — avec toute la conversation du panneau latéral de l'assistant retirée.

Et c'est le bon choix, côté produit. Le chat de GeekBye est un assistant privé que vous consultez pendant un appel — demandez-lui à propos d'une capture, obtenez une explication rapide. Ces échanges sont votre brouillon, pas une partie de la réunion. Un enregistrement de réunion que vous partageriez avec quelqu'un devrait contenir la réunion, pas vos conversations parallèles avec une IA à son sujet. La liste d'autorisés fait simplement de « la réunion » la définition littérale de ce qui est exporté.

Trois choses que cette version nous a apprises

  1. Un navigateur est un moteur de rendu PDF que vous avez déjà. Si votre document est du texte structuré et des images, printToPDF sur un BrowserWindow caché vous donne une vraie pagination, un vrai rendu de polices, et une mise en page CSS gratuitement — sans API de dessin PDF. Vous échangez une dépendance de bibliothèque contre des cas limites de navigateur, ce qui est souvent le meilleur marché.
  2. Le bug que vous voyez et le bug qui existe ne sont pas toujours le même. « Cases vides » criait données manquantes ; la cause était un conteneur coupé. Instrumentez avant de supposer — le logging de debug de l'équipe est ce qui a transformé un correctif plausible-mais-faux (filtrer les vides) en le vrai (break-inside: avoid).
  3. Le changelog est un résumé ; le filtre est la vérité. « Excludes AI responses » est une liste d'autorisés qui jette aussi vos propres questions de chat. Quand une note de version décrit un changement de comportement, le diff vous en dira les bords exacts — et ici le bord exact est « seule la réunion survit ».

Pour le chapitre précédent de l'histoire v1, de l'OCR aux pixels sans perdre le fallback (v1.8.6–v1.8.7) ; et pour tout l'arc, l'anatomie de livrer un logiciel jusqu'à la perfection.

Articles Connexes

Les deux modes de défaillance d'un overlay click-through
Steven
Steven10 min de lecture

Les deux modes de défaillance d'un overlay click-through

La fenêtre de GeekBye flotte au-dessus de tout et laisse vos clics la traverser — sauf là où elle a des boutons. C'est un contrat à deux faces, et la v1.8.5 et la v1.8.14 sont ce que ça donne quand chaque face casse : une release où l'overlay a avalé une boîte de dialogue système, une autre où il a volé vos frappes clavier. Le correctif gagnant pour la seconde a été de supprimer du code.

Ingénierie
Electron
Desktop
Né et corrigé en douze minutes
Steven
Steven8 min de lecture

Né et corrigé en douze minutes

Le changelog de GeekBye v1.8.10 dit qu'il a corrigé un crash pendant l'édition des raccourcis clavier. C'est vrai — mais le crash a été introduit et corrigé dans le même pull request, à douze minutes d'intervalle, et n'a jamais atteint un seul utilisateur. La vraie histoire est la cascade de fiabilité qui l'a produit : un petit changement correct sur le moment où les raccourcis sont suspendus, et le bug de teardown de React tombé d'une seule ligne censée rendre l'éditeur plus sûr.

Ingénierie
React
Desktop
De l'OCR aux pixels sans perdre le fallback
Steven
Steven9 min de lecture

De l'OCR aux pixels sans perdre le fallback

GeekBye a cessé de lire vos captures d'écran avec l'OCR et s'est mis à envoyer l'image réelle à des modèles de vision — ce qui est la partie facile. La partie instructive, c'est l'optimisation qui a sauté l'OCR entièrement pour les utilisateurs vision, a cassé sans bruit le fallback vers les modèles sans vision, et a dû être défaite jusqu'à une version où le filet de sécurité tourne en parallèle et ne coûte rien.

Ingénierie
AI
Desktop