Steven
Steven10 min de lectura

Imprimir una reunión a PDF sin una librería de PDF

GeekBye exporta una reunión como PDF, y no hay ninguna librería de PDF en ninguna parte del código. Renderiza HTML en una ventana de navegador invisible y la imprime. Esa elección es toda la historia: hizo la funcionalidad fácil de construir y le dio todos los fallos que tiene un navegador de verdad — un destello blanco, un límite de longitud de URL, y un salto de página que cortaba las capturas por la mitad. El arreglo del bug más feo fue una sola línea de CSS.

Ingeniería
Electron
Desktop
Lanzamientos de GeekBye
Imprimir una reunión a PDF sin una librería de PDF

Hay un truco satisfactorio escondido tras el botón «exportar esta reunión como PDF» de GeekBye: no hay ninguna librería de PDF detrás. Ni jsPDF, ni pdfmake, ni pdfkit, ni puppeteer headless. Toda la funcionalidad es un navegador oculto. La app construye una página HTML que describe la reunión — entradas de transcripción, capturas, marcas de tiempo — la carga en una ventana de Electron invisible, y le pide a esa ventana que se imprima a sí misma en PDF. webContents.printToPDF hace el resto. Esa única decisión arquitectónica es toda la historia de esta release, porque hizo la funcionalidad barata de construir y le entregó todos los bugs que tiene un navegador de verdad.

El antes es la misma release

Normalmente estos artículos abren con «así funcionaba antes». Esta vez no hay antes. El changelog de la v1.8.9 se lee como mantenimiento — "fixed page breaks splitting screenshots and filtered empty entries" — como si la exportación a PDF fuera una funcionalidad existente recibiendo un parche. No lo era. El primer commit que la introduce, feat: add PDF export via Electron printToPDF, aterriza a tres días de comenzado el mismo ciclo de release, y cada «arreglo» del changelog es iteración de la misma semana sobre código que tenía horas de vida. La funcionalidad nació, se rompió, y se endureció toda dentro de un solo tag. Vale la pena decirlo en voz alta porque es la forma honesta de mucho de lo que se publica: el punto pulido de las notas de la versión y el nacimiento caótico de la cosa son con frecuencia la misma release.

Por qué un navegador oculto siquiera

El mecanismo vale la pena entenderlo antes que los bugs, porque los bugs todos descienden de él. Todo vive en un único archivo del proceso principal, electron/ipcHandlers/pdfExportHandler.ts. Cuando el renderizador dispara el IPC 'pdf:export-report', el handler:

  1. Llama a buildReportHtml() para ensamblar una cadena HTML completa a partir de la línea de tiempo de la reunión — un bloque .item por cada entrada de transcripción o captura.
  2. Escribe ese HTML en un archivo temporal en os.tmpdir().
  3. Carga el archivo en una ventana oculta — new BrowserWindow({ show: false, opacity: 0, focusable: false, skipTaskbar: true }).
  4. Espera a que las imágenes carguen, y luego llama a win.webContents.printToPDF({ printBackground: true, margins: { … } }).
  5. Guarda el resultado en app.getPath('downloads') como Meeting-Report-<timestamp>.pdf.

El atractivo es obvio. Una librería de PDF te obliga a posicionar todo a mano — dibujar texto en (x, y), medirlo, avanzar el cursor, gestionar tú mismo los saltos de página. Un navegador ya hace todo eso: escribes <div>s y CSS, y el motor de layout de Chromium pagina por ti. No estás aprendiendo una API de dibujo; estás escribiendo una página web. Para un documento que es mayormente texto con estilo e imágenes incrustadas, eso es un atajo enorme.

La trampa es que también heredaste un navegador, con todos sus casos límite. Tres de ellos aparecieron en los primeros cuatro días.

El destello blanco. Una ventana oculta en macOS no está fiablemente oculta. Los primeros intentos la posicionaban fuera de pantalla (x: screenWidth + 1000), y aun así destellaba un rectángulo blanco en pantalla durante un fotograma antes de imprimir. El arreglo, tras tres intentos, fue dejar de depender de la posición o de show: false y poner opacity: 0 — una ventana totalmente transparente no destella ni siquiera cuando el compositor la muestra brevemente. El commit lo dice sin rodeos: "show:false with off-screen positioning still flashes on macOS."

El límite de longitud de URL. La primera versión incrustaba todo el HTML — capturas incluidas — como una URL data:. Eso funciona hasta que una reunión tiene unas cuantas capturas codificadas en base64, momento en el que la URL rebasa el límite de data-URL de ~2 MB de Chromium y la carga falla de plano con ERR_INVALID_URL. Por eso el paso 2 de arriba escribe un archivo temporal y hace loadFile() en vez de cargar una data URL: una ruta de archivo no tiene límite de longitud. (Ese ~2 MB es el único número de toda esta historia, y es una estimación inline en un comentario de código, no algo que alguien haya medido — trátalo como «suficientemente grande para chocar con él», no un umbral preciso.)

El texto sin escapar. El texto de transcripción es contenido de usuario arbitrario, y se estaba metiendo directo en la cadena HTML. Cualquier transcripción que casualmente contuviera algo como <div> o <script> rompería la página o algo peor. Así que el handler ganó una pasada de escapeHtml() sobre cada cadena que interpola. En el momento en que tu PDF es una página web, la inyección de HTML también es problema tuyo.

Las cajas vacías que no estaban vacías

Ahora el bug estrella, porque es el mejor. Los usuarios reportaron PDFs exportados con cajas vacías — entradas que mostraban una marca de tiempo y una insignia pero ninguna captura, solo espacio en blanco donde debería estar la imagen. La lectura obvia es «la exportación está perdiendo datos de imagen», y el arreglo obvio es «filtrar las entradas vacías». El equipo sí añadió ese filtro — más sobre eso en un segundo — pero no era la causa real.

La causa real era la paginación. Cada entrada es un contenedor .item que sostiene una marca de tiempo, una insignia y una imagen. Chromium, imprimiendo a PDF, se tomaba la libertad de partir una página en mitad de un item — renderizando la marca de tiempo y la insignia al pie de una página y empujando la imagen a la siguiente. Lo que parecía una caja vacía era la mitad superior de un item cuya imagen se había desbordado a la página siguiente. Los datos estaban todos ahí; el layout los había guillotinado. El commit es franco sobre el 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."

El arreglo es una línea de CSS:

.item {
  break-inside: avoid;
}

break-inside: avoid le dice al motor de layout que mantenga un item entero — si no cabe en el espacio restante de una página, mueve el item completo a la siguiente en vez de partirlo. Este es el beneficio de la arquitectura de navegador oculto expresado en miniatura: el bug más feo y más reportado de la funcionalidad se arregló no con aritmética de altura de página y llamadas manuales a addPage(), sino con una sola propiedad CSS que el navegador ya sabía honrar. No escribiste un paginador; le pediste al que heredaste que se comportara.

Dos capas de filtrado de lo realmente vacío

había entradas genuinamente vacías también — solo que no las que causaban las cajas. Duplicados en blanco de un doble Cmd+Enter rápido, capturas sin vista previa, entradas de transcripción que eran solo espacios en blanco. Estas se filtraron, y curiosamente se filtraron dos veces, en dos capas:

  • En el handler, buildReportHtml() protege cada item — if (!item.content?.trim()) return '' para el texto, y para las imágenes const safeSrc = item.preview?.startsWith('data:image/') ? item.preview : ''; if (!safeSrc) return '' — y luego .filter(Boolean) descarta los vacíos. Esa última comprobación hace doble trabajo: es una compuerta de validez y una pequeña comprobación de seguridad de que solo datos de imagen reales lleguen a la página.
  • En el origen de la línea de tiempo, en el renderizador, la misma vacuidad se filtra antes de que un item se añada siquiera — if (!entry.text?.trim()) return, if (!screenshot.preview) return.

El detalle bonito en la historia es el método. El primer commit añadió un bucle logger.debug('PDF timeline item', …) explícitamente «para depurar entradas vacías». Una vez que ese logging reveló de dónde venían de verdad los blancos — incluido un bug aparte de deduplicación de capturas donde capturas rápidas producían duplicados en blanco — el commit siguiente arregló la causa raíz aguas arriba y quitó el logging de depuración. Añade instrumentación para hallar la causa, arregla la causa, borra la instrumentación. El punto del changelog "filtered empty entries" contiene calladamente ese arco entero de añadir-y-quitar.

v1.8.12: «excludes AI responses», lee el diff

Tres releases después, un cambio más — y es una buena lección sobre leer el código en vez del changelog. La nota de la v1.8.12 dice que la exportación a PDF ahora "excludes AI responses for cleaner meeting records." Cierto, pero incompleto.

La línea de tiempo de exportación etiqueta cada item con un type: 'transcript' | 'chat_user' | 'chat_assistant' | 'screenshot'. Una línea de reunión hablada es un transcript; una pregunta que escribiste en el panel del asistente es un chat_user; la respuesta de la IA es un chat_assistant. El cambio de la v1.8.12 es una edición de nueve líneas a un archivo del renderizador, y la línea operativa es un filtro colocado justo antes de construir el payload de exportación:

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

Eso es una lista de permitidos, no una lista de bloqueados. No elimina los items chat_assistant; conserva solo transcript y screenshot y descarta todo lo demás — lo que significa que quita las propias preguntas chat_user del usuario junto con las respuestas de la IA. El cuerpo del commit es más preciso que el changelog: "PDF export keeps only transcripts and screenshots, no chat messages." El registro exportado es ahora puramente la reunión — lo que se dijo y lo que estaba en pantalla — con toda la conversación del panel lateral del asistente eliminada de él.

Y esa es la decisión correcta, en términos de producto. El chat de GeekBye es un asistente privado que consultas durante una llamada — pregúntale por una captura, obtén una explicación rápida. Esos intercambios son tu borrador, no parte de la reunión. Un registro de reunión que compartirías con alguien debería contener la reunión, no tus conversaciones paralelas con una IA sobre ella. La lista de permitidos simplemente hace de «la reunión» la definición literal de lo que se exporta.

Tres cosas que nos enseñó esta release

  1. Un navegador es un renderizador de PDF que ya tienes. Si tu documento es texto estructurado e imágenes, printToPDF sobre un BrowserWindow oculto te da paginación real, renderizado de fuentes real, y layout CSS gratis — sin API de dibujo de PDF. Cambias una dependencia de librería por casos límite de navegador, que suele ser el mejor trato.
  2. El bug que puedes ver y el bug que existe no siempre son el mismo. «Cajas vacías» gritaba datos que faltan; la causa era un contenedor partido. Instrumenta antes de asumir — el logging de depuración del equipo es lo que convirtió un arreglo plausible-pero-erróneo (filtrar vacíos) en el real (break-inside: avoid).
  3. El changelog es un resumen; el filtro es la verdad. «Excludes AI responses» es una lista de permitidos que también descarta tus propias preguntas de chat. Cuando una nota de versión describe un cambio de comportamiento, el diff te dirá sus bordes exactos — y aquí el borde exacto es «solo la reunión sobrevive».

Para el capítulo anterior de la historia v1, de OCR a píxeles sin perder el fallback (v1.8.6–v1.8.7); y para todo el arco, la anatomía de publicar software hasta la perfección.

Artículos Relacionados

Los dos modos de fallo de un overlay click-through
Steven
Steven10 min de lectura

Los dos modos de fallo de un overlay click-through

La ventana de GeekBye flota por encima de todo y deja que tus clics la atraviesen — salvo donde tiene botones. Es un contrato de dos caras, y la v1.8.5 y la v1.8.14 son lo que se ve cuando cada cara se rompe: una release donde el overlay se tragó un diálogo del sistema, otra donde te robó las pulsaciones de teclas. El arreglo ganador para la segunda fue borrar código.

Ingeniería
Electron
Desktop
Nacido y arreglado en doce minutos
Steven
Steven8 min de lectura

Nacido y arreglado en doce minutos

El changelog de GeekBye v1.8.10 dice que arregló un crash al editar los atajos de teclado. Lo hizo — pero el crash se introdujo y se arregló en el mismo pull request, con doce minutos de diferencia, y nunca llegó a un solo usuario. La historia real es la cascada de fiabilidad que lo produjo: un cambio pequeño y correcto sobre cuándo se suspenden los atajos, y el bug de teardown de React que se desprendió de una línea pensada para hacer el editor más seguro.

Ingeniería
React
Desktop
De OCR a píxeles sin perder el fallback
Steven
Steven9 min de lectura

De OCR a píxeles sin perder el fallback

GeekBye dejó de leer tus capturas con OCR y empezó a enviar la imagen real a modelos de visión — que es la parte fácil. La parte instructiva es la optimización que se saltó el OCR por completo para los usuarios con visión, rompió sin ruido el fallback a modelos sin visión, y hubo que deshacerla hasta una versión donde la red de seguridad corre en paralelo y no cuesta nada.

Ingeniería
AI
Desktop