Steven
Steven10 min de leitura

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.

Engenharia
Electron
Desktop
Lançamentos da GeekBye
Imprimir uma reunião em PDF sem uma biblioteca de PDF

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:

  1. Chama buildReportHtml() para montar uma string HTML completa a partir da timeline da reunião — um bloco .item por entrada de transcrição ou captura.
  2. Escreve esse HTML num arquivo temporário em os.tmpdir().
  3. Carrega o arquivo numa janela oculta — new BrowserWindow({ show: false, opacity: 0, focusable: false, skipTaskbar: true }).
  4. Espera as imagens carregarem, e então chama win.webContents.printToPDF({ printBackground: true, margins: { … } }).
  5. Salva o resultado em app.getPath('downloads') como Meeting-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 imagens const 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

  1. Um navegador é um renderizador de PDF que você já tem. Se seu documento é texto estruturado e imagens, printToPDF sobre um BrowserWindow oculto 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.
  2. 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).
  3. 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.

Artigos Relacionados

Os dois modos de falha de um overlay click-through
Steven
Steven10 min de leitura

Os dois modos de falha de um overlay click-through

A janela da GeekBye flutua por cima de tudo e deixa seus cliques passarem através dela — exceto onde tem botões. É um contrato de duas faces, e a v1.8.5 e a v1.8.14 são o que se vê quando cada face quebra: uma release onde o overlay engoliu um diálogo do sistema, outra onde roubou suas teclas digitadas. A correção vencedora para a segunda foi apagar código.

Engenharia
Electron
Desktop
Nascido e corrigido em doze minutos
Steven
Steven8 min de leitura

Nascido e corrigido em doze minutos

O changelog da GeekBye v1.8.10 diz que corrigiu um crash ao editar os atalhos de teclado. Corrigiu — mas o crash foi introduzido e corrigido no mesmo pull request, com doze minutos de diferença, e nunca chegou a um único usuário. A história real é a cascata de confiabilidade que o produziu: uma mudança pequena e correta sobre quando os atalhos são suspensos, e o bug de teardown de React que caiu de uma linha pensada para tornar o editor mais seguro.

Engenharia
React
Desktop
De OCR a pixels sem perder o fallback
Steven
Steven8 min de leitura

De OCR a pixels sem perder o fallback

A GeekBye parou de ler suas capturas com OCR e passou a enviar a imagem real para modelos de visão — que é a parte fácil. A parte instrutiva é a otimização que pulou o OCR por completo para os usuários com visão, quebrou sem alarde o fallback para modelos sem visão, e teve que ser desfeita até uma versão onde a rede de segurança roda em paralelo e não custa nada.

Engenharia
AI
Desktop