
Imprimir uma reunião em PDF sem uma biblioteca de PDF
A GeekBye exporta uma reunião como PDF, e não há nenhuma biblioteca de PDF em lugar nenhum do código. Ela renderiza HTML numa janela de navegador invisível e a imprime. Essa escolha é a história toda: tornou a funcionalidade fácil de construir e lhe deu todas as falhas que um navegador de verdade tem — um flash branco, um limite de comprimento de URL, e uma quebra de página que cortava as capturas ao meio. A correção do bug mais feio foi uma única linha de CSS.
Há um truque satisfatório escondido atrás do botão «exportar esta reunião como PDF» da GeekBye: não há nenhuma biblioteca de PDF por trás dele. Nada de jsPDF, nada de pdfmake, nada de pdfkit, nada de puppeteer headless. A funcionalidade inteira é um navegador oculto. O app constrói uma página HTML descrevendo a reunião — entradas de transcrição, capturas, timestamps — a carrega numa janela de Electron invisível, e pede a essa janela que se imprima em PDF. webContents.printToPDF faz o resto. Essa única escolha de arquitetura é a história toda desta release, porque tornou a funcionalidade barata de construir e lhe entregou todos os bugs que um navegador de verdade tem.
O antes é a mesma release
Normalmente estes artigos abrem com «era assim que funcionava antes». Desta vez não há antes. O changelog da v1.8.9 se lê como manutenção — "fixed page breaks splitting screenshots and filtered empty entries" — como se a exportação de PDF fosse uma funcionalidade existente levando um patch. Não era. O primeiro commit que a introduz, feat: add PDF export via Electron printToPDF, aterrissa a três dias do começo do mesmo ciclo de release, e cada «correção» do changelog é iteração da mesma semana sobre código com horas de vida. A funcionalidade nasceu, quebrou, e foi endurecida, tudo dentro de uma única tag. Vale a pena dizer isso em voz alta porque é o formato honesto de muito do que se entrega: o item polido das notas de versão e o nascimento bagunçado da coisa são frequentemente a mesma release.
Por que um navegador oculto, afinal
Vale a pena entender o mecanismo antes dos bugs, porque os bugs todos descendem dele. Tudo vive num único arquivo do processo principal, electron/ipcHandlers/pdfExportHandler.ts. Quando o renderizador dispara o IPC 'pdf:export-report', o handler:
- Chama
buildReportHtml()para montar uma string HTML completa a partir da timeline da reunião — um bloco.itempor entrada de transcrição ou captura. - Escreve esse HTML num arquivo temporário em
os.tmpdir(). - Carrega o arquivo numa janela oculta —
new BrowserWindow({ show: false, opacity: 0, focusable: false, skipTaskbar: true }). - Espera as imagens carregarem, e então chama
win.webContents.printToPDF({ printBackground: true, margins: { … } }). - Salva o resultado em
app.getPath('downloads')comoMeeting-Report-<timestamp>.pdf.
O apelo é óbvio. Uma biblioteca de PDF te faz posicionar tudo à mão — desenhar texto em (x, y), medi-lo, avançar o cursor, gerenciar você mesmo as quebras de página. Um navegador já faz tudo isso: você escreve <div>s e CSS, e o motor de layout do Chromium pagina por você. Você não está aprendendo uma API de desenho; está escrevendo uma página web. Para um documento que é na maior parte texto estilizado e imagens embutidas, isso é um atalho enorme.
A pegadinha é que você também herdou um navegador, com todos os seus casos de borda. Três deles apareceram nos primeiros quatro dias.
O flash branco. Uma janela oculta no macOS não fica ocultada de forma confiável. As primeiras tentativas a posicionavam fora da tela (x: screenWidth + 1000), e ela ainda dava flash de um retângulo branco na tela por um quadro antes de imprimir. A correção, após três tentativas, foi parar de depender da posição ou de show: false e definir opacity: 0 — uma janela totalmente transparente não dá flash nem quando o compositor a mostra brevemente. O commit diz sem rodeios: "show:false with off-screen positioning still flashes on macOS."
O limite de comprimento de URL. A primeira versão embutia todo o HTML — capturas e tudo — como uma URL data:. Isso funciona até uma reunião ter algumas capturas codificadas em base64, ponto em que a URL passa voando do limite de data-URL de ~2 MB do Chromium e o carregamento falha de vez com ERR_INVALID_URL. É por isso que o passo 2 acima escreve um arquivo temporário e faz loadFile() em vez de carregar uma data URL: um caminho de arquivo não tem limite de comprimento. (Esse ~2 MB é o único número de toda esta história, e é uma estimativa inline num comentário de código, não algo que alguém tenha medido — trate-o como «grande o suficiente para esbarrar nele», não um limiar preciso.)
O texto sem escape. O texto de transcrição é conteúdo de usuário arbitrário, e estava sendo jogado direto na string HTML. Qualquer transcrição que por acaso contivesse algo como <div> ou <script> quebraria a página ou pior. Então o handler ganhou uma passada de escapeHtml() sobre cada string que ele interpola. No instante em que seu PDF é uma página web, a injeção de HTML também é problema seu.
As caixas vazias que não estavam vazias
Agora o bug de destaque, porque é o melhor. Os usuários relatavam PDFs exportados com caixas vazias — entradas que mostravam um timestamp e um badge mas nenhuma captura, só espaço em branco onde a imagem deveria estar. A leitura óbvia é «a exportação está perdendo dados de imagem», e a correção óbvia é «filtrar as entradas vazias». O time de fato adicionou esse filtro — mais sobre isso em um segundo — mas não era a causa real.
A causa real era a paginação. Cada entrada é um contêiner .item segurando um timestamp, um badge e uma imagem. O Chromium, imprimindo em PDF, tinha o maior prazer de partir uma página no meio de um item — renderizando o timestamp e o badge no pé de uma página e empurrando a imagem para a seguinte. O que parecia uma caixa vazia era a metade de cima de um item cuja imagem tinha transbordado para a página seguinte. Os dados estavam todos ali; o layout os tinha guilhotinado. O commit é franco sobre o diagnóstico errado: "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."
A correção é uma linha de CSS:
.item {
break-inside: avoid;
}
break-inside: avoid diz ao motor de layout para manter um item inteiro — se ele não couber no espaço restante de uma página, mover o item inteiro para a seguinte em vez de parti-lo. Este é o retorno da arquitetura de navegador oculto expresso em miniatura: o bug mais feio e mais relatado da funcionalidade foi corrigido não com aritmética de altura de página e chamadas manuais a addPage(), mas com uma única propriedade CSS que o navegador já sabia honrar. Você não escreveu um paginador; você pediu ao que herdou que se comportasse.
Duas camadas de filtragem do realmente-vazio
Havia entradas genuinamente vazias também — só que não as que causavam as caixas. Duplicatas em branco de um duplo Cmd+Enter rápido, capturas sem preview, entradas de transcrição que eram só espaço em branco. Essas foram filtradas, e curiosamente foram filtradas duas vezes, em duas camadas:
- No handler,
buildReportHtml()protege cada item —if (!item.content?.trim()) return ''para o texto, e para as imagensconst safeSrc = item.preview?.startsWith('data:image/') ? item.preview : ''; if (!safeSrc) return ''— e depois.filter(Boolean)descarta os vazios. Essa última verificação faz trabalho duplo: é um portão de validade e uma pequena checagem de segurança de que só dados de imagem reais cheguem à página. - Na origem da timeline, no renderizador, o mesmo vazio é filtrado antes de um item sequer ser adicionado —
if (!entry.text?.trim()) return,if (!screenshot.preview) return.
O detalhe bacana no histórico é o método. O primeiro commit adicionou um loop logger.debug('PDF timeline item', …) explicitamente «para debugar entradas vazias». Uma vez que esse logging revelou de onde os brancos realmente vinham — incluindo um bug separado de deduplicação de capturas em que capturas rápidas produziam duplicatas em branco — o commit seguinte corrigiu a causa raiz rio acima e removeu o logging de debug. Adicione instrumentação para achar a causa, corrija a causa, apague a instrumentação. O item do changelog "filtered empty entries" contém caladamente esse arco inteiro de adicionar-e-remover.
v1.8.12: «excludes AI responses», leia o diff
Três releases depois, mais uma mudança — e é uma boa lição sobre ler o código em vez do changelog. A nota da v1.8.12 diz que a exportação de PDF agora "excludes AI responses for cleaner meeting records." Verdade, mas incompleto.
A timeline de exportação etiqueta cada item com um type: 'transcript' | 'chat_user' | 'chat_assistant' | 'screenshot'. Uma linha de reunião falada é um transcript; uma pergunta que você digitou no painel do assistente é um chat_user; a resposta da IA é um chat_assistant. A mudança da v1.8.12 é uma edição de nove linhas em um arquivo do renderizador, e a linha operativa é um filtro colocado logo antes de o payload de exportação ser construído:
timeline.filter((item) => item.type === 'transcript' || item.type === 'screenshot')
Isso é uma lista de permitidos, não uma lista de bloqueados. Ela não remove os itens chat_assistant; ela mantém só transcript e screenshot e descarta todo o resto — o que significa que retira as próprias perguntas chat_user do usuário junto com as respostas da IA. O corpo do commit é mais preciso que o changelog: "PDF export keeps only transcripts and screenshots, no chat messages." O registro exportado agora é puramente a reunião — o que foi dito e o que estava na tela — com toda a conversa do painel lateral do assistente removida dele.
E essa é a decisão certa, do ponto de vista de produto. O chat da GeekBye é um assistente privado que você consulta durante uma chamada — pergunte a ele sobre uma captura, receba uma explicação rápida. Essas trocas são seu rascunho, não parte da reunião. Um registro de reunião que você compartilharia com alguém deveria conter a reunião, não suas conversas paralelas com uma IA sobre ela. A lista de permitidos simplesmente faz de «a reunião» a definição literal do que é exportado.
Três coisas que esta release nos ensinou
- Um navegador é um renderizador de PDF que você já tem. Se seu documento é texto estruturado e imagens,
printToPDFsobre umBrowserWindowoculto te dá paginação real, renderização de fontes real, e layout CSS de graça — sem API de desenho de PDF. Você troca uma dependência de biblioteca por casos de borda de navegador, o que costuma ser o melhor negócio. - O bug que você vê e o bug que existe nem sempre são o mesmo. «Caixas vazias» gritava dados faltando; a causa era um contêiner partido. Instrumente antes de assumir — o logging de debug do time é o que transformou uma correção plausível-mas-errada (filtrar vazios) na real (
break-inside: avoid). - O changelog é um resumo; o filtro é a verdade. «Excludes AI responses» é uma lista de permitidos que também descarta suas próprias perguntas de chat. Quando uma nota de versão descreve uma mudança de comportamento, o diff vai te contar as bordas exatas dela — e aqui a borda exata é «só a reunião sobrevive».
Para o capítulo anterior da história v1, de OCR a pixels sem perder o fallback (v1.8.6–v1.8.7); e para todo o arco, a anatomia de entregar software até a perfeição.