--- title: 'Printing a Meeting to PDF Without a PDF Library' excerpt: 'GeekBye exports a meeting as a PDF, and there is no PDF library anywhere in the code. It renders HTML in an invisible browser window and prints it. That choice is the whole story: it made the feature easy to build and gave it every failure a real browser has — a white flash, a URL length limit, and a page break that cut screenshots in half. The fix for the ugliest bug was one line of CSS.' date: '2026-07-20' author: 'Steven' authorAvatar: '/images/blog/authors/steven.jpg' coverImage: '/images/blog/covers/printing-a-meeting-to-pdf-without-a-pdf-library.svg' tags: ['Engineering', 'Electron', 'Desktop', 'GeekBye Releases'] keywords: - 'electron printtopdf export html to pdf' - 'generate pdf without jspdf pdfmake library' - 'pdf page break inside avoid css screenshot' - 'hidden browserwindow render html print pdf electron' - 'data url size limit err_invalid_url base64 image' - 'export meeting transcript screenshots to pdf' lastModified: '2026-07-20' tldr: "GeekBye's PDF export doesn't use jsPDF, pdfmake, or puppeteer — it uses Electron's `webContents.printToPDF`, which means it builds an HTML string, loads it into a hidden `BrowserWindow`, and prints that window to PDF. The feature was born, iterated ten times, and hardened all inside a single release (v1.8.9) — there is no earlier version to compare against. Choosing a real browser as the renderer made the layout free (HTML and CSS you already know) but inherited every browser problem: a white window flash on macOS (fixed with `opacity: 0`, not `show: false`), a `data:` URL length limit that broke on base64 screenshots (fixed by writing a temp HTML file), and unescaped transcript text that could inject markup. The headline bug — screenshots cut in half at page boundaries, which users saw as *empty boxes* — turned out to be a rendering artifact, not missing data, and the fix was a single CSS line: `break-inside: avoid`. Three releases later, v1.8.12 stopped exporting the chat thread — the changelog says 'excludes AI responses,' but the filter is an allowlist that keeps only transcript and screenshots, so it drops the user's own chat questions too." keyTakeaways: - "Electron's `printToPDF` lets you generate a PDF with zero PDF libraries: build an HTML string, load it into a hidden `BrowserWindow`, print the window — you lay out the document in HTML/CSS you already know instead of a PDF drawing API" - 'Rendering in a real browser inherits real-browser bugs: a macOS window flash (solved with `opacity: 0`, because `show: false` off-screen still flashes), a `~2 MB` `data:` URL limit that base64 screenshots blow past (solved by loading a temp file instead of a data URL), and unescaped user text that needs an `escapeHtml()` pass' - "The 'empty boxes' in the exported PDF were not empty data — they were `.item` containers that Chromium split across a page boundary, rendering the timestamp on one page and the image on the next; the fix was one CSS line, `break-inside: avoid`, not a filtering change" - 'When a bug has two plausible causes (missing data vs. bad layout), instrument first: the team added timeline debug logging to find the real source of empty entries, fixed the layout root cause, then deleted the logging — add-to-find, remove-when-found' - "Read the diff, not the changelog: v1.8.12's 'excludes AI responses' is implemented as an allowlist (`type === 'transcript' || type === 'screenshot'`), which strips the entire chat side-panel — the user's typed questions as well as the AI's answers — so the exported record is purely the spoken meeting plus screenshots" --- There is a satisfying trick hiding in GeekBye's "export this meeting as a PDF" button: there is no PDF library behind it. No jsPDF, no pdfmake, no pdfkit, no headless puppeteer. The entire feature is a hidden browser. The app builds an HTML page describing the meeting — transcript entries, screenshots, timestamps — loads it into an invisible Electron window, and asks that window to print itself to PDF. `webContents.printToPDF` does the rest. That single architectural choice is the whole story of this release, because it made the feature cheap to build and handed it every bug a real browser has. ## The before is the same release Usually these posts open with "here's how it worked before." This time there is no before. The changelog for v1.8.9 reads like maintenance — "fixed page breaks splitting screenshots and filtered empty entries" — as if PDF export were an existing feature getting patched. It wasn't. The first commit that introduces it, `feat: add PDF export via Electron printToPDF`, lands three days into the same release cycle, and every "fix" in the changelog is same-week iteration on code that was hours old. The feature was born, broke, and got hardened all inside one tag. That's worth saying out loud because it's the honest shape of a lot of shipping: the polished bullet in the release notes and the messy birth of the thing are frequently the same release. ## Why a hidden browser at all The mechanism is worth understanding before the bugs, because the bugs all descend from it. Everything lives in one main-process file, `electron/ipcHandlers/pdfExportHandler.ts`. When the renderer fires the `'pdf:export-report'` IPC, the handler: 1. Calls `buildReportHtml()` to assemble a full HTML string from the meeting's timeline — one `.item` block per transcript entry or screenshot. 2. Writes that HTML to a temp file in `os.tmpdir()`. 3. Loads the file into a hidden window — `new BrowserWindow({ show: false, opacity: 0, focusable: false, skipTaskbar: true })`. 4. Waits for the images to load, then calls `win.webContents.printToPDF({ printBackground: true, margins: { … } })`. 5. Saves the result to `app.getPath('downloads')` as `Meeting-Report-.pdf`. The appeal is obvious. A PDF library makes you position everything by hand — draw text at (x, y), measure it, advance the cursor, manage page breaks yourself. A browser already does all of that: you write `
`s and CSS, and Chromium's layout engine paginates for you. You're not learning a drawing API; you're writing a web page. For a document that's mostly styled text and embedded images, that's a huge shortcut. The catch is that you also inherited a browser, with all of its edge cases. Three of them showed up within the first four days. **The white flash.** A hidden window on macOS isn't reliably hidden. The first attempts positioned it off-screen (`x: screenWidth + 1000`), and it still flashed a white rectangle on screen for a frame before printing. The fix, after three tries, was to stop relying on position or `show: false` and set `opacity: 0` — a fully transparent window doesn't flash even when the compositor briefly shows it. The commit says it plainly: "show:false with off-screen positioning still flashes on macOS." **The URL length limit.** The first version inlined the whole HTML — screenshots and all — as a `data:` URL. That works until a meeting has a few base64-encoded screenshots in it, at which point the URL sails past Chromium's `~2 MB` data-URL limit and the load fails outright with `ERR_INVALID_URL`. That's why step 2 above writes a temp _file_ and does `loadFile()` instead of loading a data URL: a file path has no length limit. (That `~2 MB` is the one number in this whole story, and it's an inline estimate in a code comment, not something anyone benchmarked — treat it as "big enough to hit," not a precise threshold.) **The unescaped text.** Transcript text is arbitrary user content, and it was being dropped straight into the HTML string. Any transcript that happened to contain something like `
` or `