
Εκτύπωση μιας σύσκεψης σε PDF χωρίς βιβλιοθήκη PDF
Το GeekBye εξάγει μια σύσκεψη ως PDF, και δεν υπάρχει πουθενά στον κώδικα καμία βιβλιοθήκη PDF. Κάνει render HTML σε ένα αόρατο παράθυρο browser και το εκτυπώνει. Αυτή η επιλογή είναι όλη η ιστορία: έκανε τη λειτουργία εύκολη στην κατασκευή και της έδωσε κάθε αστοχία που έχει ένας πραγματικός browser — ένα λευκό τρεμόπαιγμα, ένα όριο μήκους URL και ένα σπάσιμο σελίδας που έκοβε τα στιγμιότυπα οθόνης στη μέση. Η διόρθωση για το πιο άσχημο bug ήταν μία γραμμή CSS.
Υπάρχει ένα ικανοποιητικό κόλπο κρυμμένο στο κουμπί «εξαγωγή αυτής της σύσκεψης ως PDF» του GeekBye: δεν υπάρχει καμία βιβλιοθήκη PDF πίσω του. Ούτε jsPDF, ούτε pdfmake, ούτε pdfkit, ούτε headless puppeteer. Ολόκληρη η λειτουργία είναι ένας κρυφός browser. Η εφαρμογή φτιάχνει μια σελίδα HTML που περιγράφει τη σύσκεψη — εγγραφές μεταγραφής, στιγμιότυπα οθόνης, timestamps — τη φορτώνει σε ένα αόρατο παράθυρο Electron και ζητά από αυτό το παράθυρο να εκτυπώσει τον εαυτό του σε PDF. Το webContents.printToPDF κάνει τα υπόλοιπα. Αυτή η μοναδική αρχιτεκτονική επιλογή είναι όλη η ιστορία αυτής της έκδοσης, επειδή έκανε τη λειτουργία φθηνή στην κατασκευή και της παρέδωσε κάθε bug που έχει ένας πραγματικός browser.
Το «πριν» είναι η ίδια έκδοση
Συνήθως αυτές οι αναρτήσεις ανοίγουν με «να πώς δούλευε πριν». Αυτή τη φορά δεν υπάρχει πριν. Το changelog για το v1.8.9 διαβάζεται σαν συντήρηση — "fixed page breaks splitting screenshots and filtered empty entries" — σαν η εξαγωγή PDF να ήταν μια υπάρχουσα λειτουργία που δέχεται patch. Δεν ήταν. Το πρώτο commit που την εισάγει, feat: add PDF export via Electron printToPDF, προσγειώνεται τρεις μέρες μέσα στον ίδιο κύκλο έκδοσης, και κάθε «fix» στο changelog είναι επανάληψη της ίδιας εβδομάδας πάνω σε κώδικα που ήταν ωρών. Η λειτουργία γεννήθηκε, έσπασε και θωρακίστηκε, όλα μέσα σε ένα tag. Αξίζει να ειπωθεί φωναχτά επειδή είναι το ειλικρινές σχήμα πολλής παράδοσης: το γυαλισμένο bullet στις σημειώσεις έκδοσης και η ακατάστατη γέννηση του πράγματος είναι συχνά η ίδια έκδοση.
Γιατί κρυφός browser εξαρχής
Ο μηχανισμός αξίζει να κατανοηθεί πριν από τα bugs, επειδή όλα τα bugs κατάγονται από αυτόν. Όλα ζουν σε ένα αρχείο της κύριας διεργασίας, electron/ipcHandlers/pdfExportHandler.ts. Όταν ο renderer πυροδοτεί το 'pdf:export-report' IPC, ο handler:
- Καλεί το
buildReportHtml()για να συναρμολογήσει ένα πλήρες HTML string από το timeline της σύσκεψης — ένα μπλοκ.itemανά εγγραφή μεταγραφής ή στιγμιότυπο οθόνης. - Γράφει αυτό το HTML σε ένα προσωρινό αρχείο στο
os.tmpdir(). - Φορτώνει το αρχείο σε ένα κρυφό παράθυρο —
new BrowserWindow({ show: false, opacity: 0, focusable: false, skipTaskbar: true }). - Περιμένει να φορτωθούν οι εικόνες, μετά καλεί το
win.webContents.printToPDF({ printBackground: true, margins: { … } }). - Αποθηκεύει το αποτέλεσμα στο
app.getPath('downloads')ωςMeeting-Report-<timestamp>.pdf.
Η έλξη είναι προφανής. Μια βιβλιοθήκη PDF σε βάζει να τοποθετείς τα πάντα με το χέρι — σχεδίασε κείμενο στο (x, y), μέτρησέ το, προχώρα τον δρομέα, διαχειρίσου μόνος σου τα σπασίματα σελίδων. Ένας browser τα κάνει ήδη όλα αυτά: γράφεις <div> και CSS, και η μηχανή διάταξης του Chromium σελιδοποιεί για σένα. Δεν μαθαίνεις ένα drawing API· γράφεις μια ιστοσελίδα. Για ένα έγγραφο που είναι κυρίως στιλιζαρισμένο κείμενο και ενσωματωμένες εικόνες, αυτό είναι μια τεράστια συντόμευση.
Το κόλπο είναι ότι κληρονόμησες επίσης έναν browser, με όλες τις ακραίες περιπτώσεις του. Τρεις από αυτές εμφανίστηκαν μέσα στις πρώτες τέσσερις μέρες.
Το λευκό τρεμόπαιγμα. Ένα κρυφό παράθυρο στο macOS δεν είναι αξιόπιστα κρυφό. Οι πρώτες προσπάθειες το τοποθέτησαν εκτός οθόνης (x: screenWidth + 1000), και πάλι τρεμόπαιζε ένα λευκό ορθογώνιο στην οθόνη για ένα καρέ πριν την εκτύπωση. Η διόρθωση, μετά από τρεις προσπάθειες, ήταν να πάψει να στηρίζεται στη θέση ή στο show: false και να θέσει opacity: 0 — ένα πλήρως διαφανές παράθυρο δεν τρεμοπαίζει ακόμα κι όταν ο compositor το δείξει για λίγο. Το commit το λέει ξεκάθαρα: "show:false with off-screen positioning still flashes on macOS."
Το όριο μήκους URL. Η πρώτη έκδοση έβαζε inline ολόκληρο το HTML — στιγμιότυπα οθόνης και όλα — ως data: URL. Αυτό δουλεύει μέχρι μια σύσκεψη να έχει μερικά base64-κωδικοποιημένα στιγμιότυπα οθόνης μέσα της, οπότε το URL περνά το όριο data-URL του Chromium των ~2 MB και η φόρτωση αποτυγχάνει εντελώς με ERR_INVALID_URL. Γι' αυτό το βήμα 2 παραπάνω γράφει ένα προσωρινό αρχείο και κάνει loadFile() αντί να φορτώνει ένα data URL: μια διαδρομή αρχείου δεν έχει όριο μήκους. (Εκείνα τα ~2 MB είναι ο ένας αριθμός σε όλη αυτή την ιστορία, και είναι μια inline εκτίμηση σε ένα σχόλιο κώδικα, όχι κάτι που κάποιος έκανε benchmark — αντιμετώπισέ το ως «αρκετά μεγάλο για να το χτυπήσεις», όχι ως ακριβές κατώφλι.)
Το μη-escaped κείμενο. Το κείμενο μεταγραφής είναι αυθαίρετο περιεχόμενο χρήστη, και πεταγόταν κατευθείαν μέσα στο HTML string. Οποιαδήποτε μεταγραφή τύχαινε να περιέχει κάτι σαν <div> ή <script> θα έσπαγε τη σελίδα ή χειρότερα. Έτσι ο handler απέκτησε ένα πέρασμα escapeHtml() πάνω σε κάθε string που παρεμβάλλει. Τη στιγμή που το PDF σου είναι μια ιστοσελίδα, το HTML injection είναι κι αυτό δικό σου πρόβλημα.
Τα άδεια κουτιά που δεν ήταν άδεια
Τώρα το κύριο bug, επειδή είναι το καλύτερο. Οι χρήστες ανέφεραν εξαγόμενα PDF με άδεια κουτιά — εγγραφές που έδειχναν ένα timestamp και ένα badge αλλά κανένα στιγμιότυπο οθόνης, μόνο κενό χώρο εκεί που θα έπρεπε να είναι η εικόνα. Η προφανής ανάγνωση είναι «η εξαγωγή πετάει δεδομένα εικόνας», και η προφανής διόρθωση είναι «φιλτράρισε τις άδειες εγγραφές». Η ομάδα όντως πρόσθεσε αυτό το φίλτρο — περισσότερα γι' αυτό σε ένα δευτερόλεπτο — αλλά δεν ήταν η πραγματική αιτία.
Η πραγματική αιτία ήταν η σελιδοποίηση. Κάθε εγγραφή είναι ένα .item container που κρατά ένα timestamp, ένα badge και μια εικόνα. Το Chromium, εκτυπώνοντας σε PDF, ευχαρίστως έσπαγε μια σελίδα στη μέση ενός item — κάνοντας render το timestamp και το badge στο κάτω μέρος μιας σελίδας και σπρώχνοντας την εικόνα στην επόμενη. Αυτό που έμοιαζε με άδειο κουτί ήταν το πάνω μισό ενός item του οποίου η εικόνα είχε ξεχειλίσει στην επόμενη σελίδα. Τα δεδομένα ήταν όλα εκεί· η διάταξη τα είχε γκιλοτινίσει. Το commit είναι ειλικρινές για τη λάθος διάγνωση: "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."
Η διόρθωση είναι μία γραμμή CSS:
.item {
break-inside: avoid;
}
Το break-inside: avoid λέει στη μηχανή διάταξης να κρατά ένα item ολόκληρο — αν δεν χωράει στον υπόλοιπο χώρο μιας σελίδας, μετακίνησε ολόκληρο το item στην επόμενη σελίδα αντί να το σπάσεις. Αυτή είναι η ανταμοιβή της αρχιτεκτονικής κρυφού browser σε μινιατούρα: το πιο άσχημο, πιο αναφερόμενο bug της λειτουργίας διορθώθηκε όχι με αριθμητική ύψους σελίδας και χειροκίνητες κλήσεις addPage(), αλλά με μία μοναδική ιδιότητα CSS που ο browser ήξερε ήδη πώς να τιμήσει. Δεν έγραψες έναν σελιδοποιητή· ζήτησες από αυτόν που κληρονόμησες να συμπεριφερθεί.
Δύο επίπεδα φιλτραρίσματος των πραγματικά άδειων
Υπήρχαν και πραγματικά άδειες εγγραφές — απλώς όχι αυτές που προκαλούσαν τα κουτιά. Άδεια διπλότυπα από ένα γρήγορο διπλό Cmd+Enter, στιγμιότυπα οθόνης χωρίς preview, εγγραφές μεταγραφής που ήταν μόνο κενά. Αυτά φιλτραρίστηκαν, και είναι ενδιαφέρον ότι φιλτραρίστηκαν δύο φορές, σε δύο επίπεδα:
- Στον handler, το
buildReportHtml()φυλάει κάθε item —if (!item.content?.trim()) return ''για κείμενο, και για εικόνεςconst safeSrc = item.preview?.startsWith('data:image/') ? item.preview : ''; if (!safeSrc) return ''— μετά το.filter(Boolean)πετάει τα άδεια. Εκείνος ο τελευταίος έλεγχος κάνει διπλή δουλειά: είναι μια πύλη εγκυρότητας και ένας μικρός έλεγχος ασφαλείας ότι μόνο πραγματικά δεδομένα εικόνας φτάνουν ποτέ στη σελίδα. - Στην πηγή του timeline, στον renderer, η ίδια κενότητα φιλτράρεται πριν καν προστεθεί ένα item —
if (!entry.text?.trim()) return,if (!screenshot.preview) return.
Η ωραία λεπτομέρεια στην ιστορία είναι η μέθοδος. Το πρώτο commit πρόσθεσε έναν βρόχο logger.debug('PDF timeline item', …) ρητά «για debugging των άδειων εγγραφών». Μόλις εκείνο το logging αποκάλυψε από πού έρχονταν πραγματικά τα κενά — συμπεριλαμβανομένου ενός ξεχωριστού bug απαλοιφής διπλότυπων στιγμιότυπων όπου οι γρήγορες λήψεις παρήγαγαν άδεια διπλότυπα — το επόμενο commit διόρθωσε τη ριζική αιτία ανάντη και αφαίρεσε το debug logging. Πρόσθεσε όργανα για να βρεις την αιτία, διόρθωσε την αιτία, διέγραψε τα όργανα. Το bullet του changelog "filtered empty entries" περιέχει διακριτικά ολόκληρο αυτό το τόξο πρόσθεσε-μετά-αφαίρεσε.
v1.8.12: "excludes AI responses," διάβασε το diff
Τρεις εκδόσεις αργότερα, μία ακόμα αλλαγή — και είναι ένα καλό μάθημα στο να διαβάζεις τον κώδικα αντί για το changelog. Η σημείωση του v1.8.12 λέει ότι η εξαγωγή PDF τώρα "excludes AI responses for cleaner meeting records." Αληθές, αλλά ελλιπές.
Το timeline εξαγωγής ταγκάρει κάθε item με έναν type: 'transcript' | 'chat_user' | 'chat_assistant' | 'screenshot'. Μια προφορική γραμμή σύσκεψης είναι ένα transcript· μια ερώτηση που πληκτρολόγησες στο panel του βοηθού είναι ένα chat_user· η απάντηση του AI είναι ένα chat_assistant. Η αλλαγή του v1.8.12 είναι μια επεξεργασία εννέα γραμμών σε ένα αρχείο renderer, και η καθοριστική γραμμή είναι ένα φίλτρο τοποθετημένο ακριβώς πριν χτιστεί το export payload:
timeline.filter((item) => item.type === 'transcript' || item.type === 'screenshot')
Αυτό είναι ένα allowlist, όχι blocklist. Δεν αφαιρεί τα chat_assistant items· κρατά μόνο transcript και screenshot και πετάει όλα τα υπόλοιπα — πράγμα που σημαίνει ότι αφαιρεί τις ίδιες τις chat_user ερωτήσεις του χρήστη μαζί με τις απαντήσεις του AI. Το σώμα του commit είναι πιο ακριβές από το changelog: "PDF export keeps only transcripts and screenshots, no chat messages." Η εξαγόμενη εγγραφή είναι τώρα καθαρά η σύσκεψη — τι ειπώθηκε και τι ήταν στην οθόνη — με ολόκληρη τη συνομιλία του πλαϊνού panel του βοηθού αφαιρεμένη από αυτήν.
Και αυτή είναι η σωστή επιλογή, από άποψη προϊόντος. Το chat του GeekBye είναι ένας ιδιωτικός βοηθός που συμβουλεύεσαι κατά τη διάρκεια μιας κλήσης — ρώτησέ τον για ένα στιγμιότυπο οθόνης, πάρε μια γρήγορη εξήγηση. Αυτές οι ανταλλαγές είναι το πρόχειρό σου, όχι μέρος της σύσκεψης. Μια εγγραφή σύσκεψης που θα μοιραζόσουν με κάποιον θα έπρεπε να περιέχει τη σύσκεψη, όχι τις παράπλευρες συνομιλίες σου με ένα AI γι' αυτήν. Το allowlist απλώς κάνει «τη σύσκεψη» τον κυριολεκτικό ορισμό του τι εξάγεται.
Τρία πράγματα που μας δίδαξε αυτή η έκδοση
- Ένας browser είναι ένας renderer PDF που ήδη έχεις. Αν το έγγραφό σου είναι δομημένο κείμενο και εικόνες, το
printToPDFπάνω σε ένα κρυφόBrowserWindowσου δίνει πραγματική σελιδοποίηση, πραγματικό render γραμματοσειρών και CSS διάταξη δωρεάν — κανένα PDF drawing API. Ανταλλάσσεις μια εξάρτηση βιβλιοθήκης με ακραίες περιπτώσεις browser, που συχνά είναι η καλύτερη ανταλλαγή. - Το bug που μπορείς να δεις και το bug που υπάρχει δεν είναι πάντα το ίδιο. Τα «άδεια κουτιά» ούρλιαζαν δεδομένα που λείπουν· η αιτία ήταν ένα σπασμένο container. Βάλε όργανα πριν υποθέσεις — το debug logging της ομάδας είναι αυτό που μετέτρεψε μια εύλογη-αλλά-λάθος διόρθωση (φιλτράρισε τα άδεια) στην πραγματική (
break-inside: avoid). - Το changelog είναι μια περίληψη· το φίλτρο είναι η αλήθεια. Το "Excludes AI responses" είναι ένα allowlist που πετάει και τις δικές σου chat ερωτήσεις. Όταν μια σημείωση έκδοσης περιγράφει μια αλλαγή συμπεριφοράς, το diff θα σου πει τα ακριβή όριά της — και εδώ το ακριβές όριο είναι «επιβιώνει μόνο η σύσκεψη».
Για το προηγούμενο κεφάλαιο της ιστορίας του v1, από το OCR στα pixels χωρίς να χάσουμε το fallback (v1.8.6–v1.8.7)· και για ολόκληρη την πορεία, η ανατομία της παράδοσης λογισμικού στην τελειότητα.