
PDF 라이브러리 없이 회의를 PDF로 인쇄하기
GeekBye는 회의를 PDF로 내보내는데, 코드 어디에도 PDF 라이브러리가 없습니다. 보이지 않는 브라우저 창에서 HTML을 렌더링하고 그것을 인쇄합니다. 그 선택이 이 이야기의 전부입니다: 그것은 기능을 만들기 쉽게 했고, 동시에 진짜 브라우저가 가진 온갖 고장을 안겨 주었습니다 — 흰 깜빡임, URL 길이 제한, 그리고 스크린샷을 반으로 자른 페이지 나눔. 가장 못생긴 버그의 해결책은 CSS 한 줄이었습니다.
GeekBye의 "이 회의를 PDF로 내보내기" 버튼에는 흡족한 트릭이 하나 숨어 있습니다: 그 뒤에 PDF 라이브러리가 하나도 없습니다. jsPDF도 없고, pdfmake도 없고, pdfkit도 없고, 헤드리스 puppeteer도 없습니다. 기능 전체가 하나의 숨긴 브라우저입니다. 앱은 회의를 기술하는 HTML 페이지 — 트랜스크립트 항목, 스크린샷, 타임스탬프 — 를 짓고, 보이지 않는 Electron 창에 불러들이고, 그 창에게 스스로를 PDF로 인쇄하라고 청합니다. 나머지는 webContents.printToPDF가 합니다. 그 하나의 아키텍처적 선택이 이 릴리스 이야기의 전부입니다. 왜냐하면 그것이 기능을 값싸게 만들게 했고, 동시에 진짜 브라우저가 가진 온갖 버그를 안겨 주었기 때문입니다.
이전도 같은 릴리스다
보통 이런 글들은 "이전에는 이렇게 동작했다"로 시작합니다. 이번에는 이전이 없습니다. v1.8.9의 changelog는 유지보수처럼 읽힙니다 — "fixed page breaks splitting screenshots and filtered empty entries" — 마치 PDF 내보내기가 패치를 받는 기존 기능인 것처럼. 그렇지 않았습니다. 그것을 도입하는 첫 커밋, feat: add PDF export via Electron printToPDF는 같은 릴리스 주기의 사흘째에 착륙하고, changelog 속 모든 "수정"은 몇 시간밖에 안 된 코드에 대한 같은 주의 반복입니다. 기능은 태어나고, 부서지고, 한 태그 안에서 모두 단단해졌습니다. 그걸 소리 내어 말할 가치가 있는 이유는, 그것이 많은 출시의 정직한 모습이기 때문입니다: 릴리스 노트의 다듬어진 항목과 그 물건의 어수선한 탄생은 흔히 같은 릴리스입니다.
대체 왜 숨긴 브라우저인가
메커니즘은 버그보다 먼저 이해해 둘 값어치가 있습니다. 버그가 모두 거기서 파생하기 때문입니다. 모든 것은 하나의 메인 프로세스 파일, electron/ipcHandlers/pdfExportHandler.ts에 담겨 있습니다. 렌더러가 'pdf:export-report' IPC를 쏘면, 핸들러는:
buildReportHtml()을 불러 회의의 타임라인으로부터 완전한 HTML 문자열을 조립합니다 — 트랜스크립트 항목이나 스크린샷마다 하나의.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)에 텍스트를 그리고, 그것을 재고, 커서를 전진시키고, 페이지 나눔을 스스로 관리한다. 브라우저는 이미 그 모두를 합니다: 당신은 <div>와 CSS를 쓰고, Chromium의 레이아웃 엔진이 당신을 위해 페이지를 나눕니다. 당신은 그리기 API를 배우는 게 아닙니다; 웹 페이지를 쓰는 것입니다. 대부분이 스타일 입힌 텍스트와 박아 넣은 이미지인 문서에게 그것은 어마어마한 지름길입니다.
함정은 당신이 브라우저 또한 — 그 온갖 엣지 케이스와 함께 — 물려받았다는 것입니다. 그중 셋이 첫 나흘 안에 나타났습니다.
흰 깜빡임. macOS에서 숨긴 창은 미덥게 숨겨지지 않습니다. 첫 시도들은 그것을 화면 밖(x: screenWidth + 1000)에 두었지만, 그래도 인쇄 전에 한 프레임 동안 흰 사각형이 화면에 번쩍였습니다. 세 번의 시도 끝의 해결책은, 위치나 show: false에 기대는 것을 그만두고 opacity: 0을 설정하는 것이었습니다 — 완전히 투명한 창은 컴포지터가 잠깐 그것을 보여 주어도 깜빡이지 않습니다. 커밋은 그것을 대놓고 말합니다: "show:false with off-screen positioning still flashes on macOS."
URL 길이 제한. 첫 버전은 HTML 전체 — 스크린샷까지 몽땅 — 를 하나의 data: URL로 인라인했습니다. 그것은 회의에 base64로 인코딩된 스크린샷이 몇 장 들어가기 전까지는 동작합니다. 그 지점에서 URL은 Chromium의 ~2 MB data-URL 제한을 넘어 미끄러져 가고, 로드는 ERR_INVALID_URL과 함께 통째로 실패합니다. 그래서 위의 2단계는 data URL을 불러들이는 대신 임시 _파일_을 쓰고 loadFile()을 하는 것입니다: 파일 경로에는 길이 제한이 없습니다. (그 ~2 MB는 이 이야기 전체에서 유일한 숫자이고, 코드 주석 속의 인라인 추정치이지 누가 벤치마크한 것이 아닙니다 — 「부딪히기에 충분히 큰」 값으로 받아들이지, 정밀한 임계값으로 받아들이지 마세요.)
이스케이프되지 않은 텍스트. 트랜스크립트 텍스트는 임의의 사용자 콘텐츠이고, 그것이 HTML 문자열로 곧장 떨어져 들어가고 있었습니다. 우연히 <div>나 <script> 같은 것을 담은 트랜스크립트는 페이지를 부수거나 더 나쁜 짓을 할 수 있었습니다. 그래서 핸들러는 보간하는 모든 문자열에 걸친 escapeHtml() 처리를 갖추게 되었습니다. 당신의 PDF가 웹 페이지인 순간, HTML 주입도 당신의 문제입니다.
비어 있지 않았던 빈 상자
이제 헤드라인 버그입니다, 이게 제일 좋은 것이니까. 사용자들은 빈 상자가 있는 내보낸 PDF를 보고했습니다 — 타임스탬프와 배지는 보이는데 스크린샷은 없고, 이미지가 있어야 할 자리가 그저 빈 공간인 항목들. 뻔한 읽기는 "내보내기가 이미지 데이터를 떨어뜨리고 있다"이고, 뻔한 해결책은 "빈 항목을 필터로 걸러내라"입니다. 팀은 실제로 그 필터를 더했습니다 — 그것은 잠시 뒤에 — 하지만 그게 진짜 원인은 아니었습니다.
진짜 원인은 페이지 나눔이었습니다. 각 항목은 타임스탬프와 배지와 이미지를 품은 하나의 .item 컨테이너입니다. Chromium은 PDF로 인쇄하면서 항목 한가운데서 페이지를 나누는 것을 태연히 했습니다 — 타임스탬프와 배지를 한 페이지의 아래쪽에 그리고, 이미지를 다음 페이지로 밀어낸 것입니다. 빈 상자처럼 보인 것은, 이미지가 다음 페이지로 넘쳐 버린 항목의 위쪽 절반이었습니다. 데이터는 다 거기 있었고; 레이아웃이 그것을 단두대에 올렸던 것입니다. 커밋은 그 오진에 대해 솔직합니다: "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는 레이아웃 엔진에게 한 항목을 통째로 지키라고 말합니다 — 페이지의 남은 공간에 들어가지 않으면, 그것을 쪼개는 대신 항목 전체를 다음 페이지로 옮겨라. 이것은 숨긴 브라우저 아키텍처의 보상을 축소판으로 말한 것입니다: 이 기능에서 가장 못생기고 가장 많이 보고된 버그가, 페이지 높이 산수와 손수 하는 addPage() 호출로가 아니라, 브라우저가 이미 지킬 줄 알던 단 하나의 CSS 속성으로 고쳐졌습니다. 당신은 페이지 나누미를 쓰지 않았습니다; 물려받은 그것에게 얌전히 굴라고 청한 것입니다.
정말로 빈 것을 거르는 두 겹
정말로 빈 항목도 있었습니다 — 그저 상자를 일으킨 것들이 아니었을 뿐. 재빠른 이중 Cmd+Enter에서 온 빈 중복, 미리보기가 없는 스크린샷, 공백뿐인 트랜스크립트 항목. 이것들은 걸러졌고, 흥미롭게도 두 번, 두 겹에서 걸러졌습니다:
- 핸들러 안에서,
buildReportHtml()은 각 항목을 지킵니다 — 텍스트에는if (!item.content?.trim()) return '', 이미지에는const safeSrc = item.preview?.startsWith('data:image/') ? item.preview : ''; if (!safeSrc) return ''— 그런 뒤.filter(Boolean)이 빈 것을 떨어뜨립니다. 그 마지막 검사는 두 몫을 합니다: 그것은 유효성 관문이자 그리고, 진짜 이미지 데이터만이 페이지에 닿는다는 작은 안전 확인이기도 합니다. - 타임라인 출처에서, 렌더러 쪽에서는, 항목이 더해지기도 전에 같은 비어 있음이 걸러집니다 —
if (!entry.text?.trim()) return,if (!screenshot.preview) return.
역사 속의 멋진 디테일은 그 방법입니다. 첫 커밋은 logger.debug('PDF timeline item', …) 루프를 명시적으로 "for debugging empty entries"를 위해 더했습니다. 그 로깅이 빈칸이 실제로 어디서 오는지 — 재빠른 캡처가 빈 중복을 만드는 별개의 스크린샷 중복 제거 버그를 포함해 — 를 드러내자, 뒤따른 커밋은 근본 원인을 상류에서 고치고 디버그 로깅을 없앴습니다. 원인을 찾으려 계측을 더하고, 원인을 고치고, 계측을 지운다. changelog 항목 "filtered empty entries"는 그 더했다가 없애는 호 전체를 조용히 품고 있습니다.
v1.8.12: "excludes AI responses", diff를 읽어라
세 릴리스 뒤, 변경 하나 더 — 그리고 그것은 changelog가 아니라 코드를 읽는 것에 대한 좋은 교훈입니다. v1.8.12 노트는 PDF 내보내기가 이제 "excludes AI responses for cleaner meeting records"라고 말합니다. 참이지만 불완전합니다.
내보내기 타임라인은 각 항목에 type을 태그합니다: 'transcript' | 'chat_user' | 'chat_assistant' | 'screenshot'. 말해진 회의의 한 줄은 transcript; 어시스턴트 패널에 당신이 타이핑한 질문은 chat_user; AI의 답변은 chat_assistant입니다. v1.8.12 변경은 렌더러 파일 하나에 대한 아홉 줄 편집이고, 요체가 되는 줄은 내보내기 페이로드가 지어지기 바로 직전에 놓인 필터입니다:
timeline.filter((item) => item.type === 'transcript' || item.type === 'screenshot')
그것은 차단 목록이 아니라 허용 목록입니다. 그것은 chat_assistant 항목을 제거하는 게 아닙니다; transcript와 screenshot만 남기고 나머지 전부를 떨어뜨립니다 — 즉 그것은 AI의 답변과 나란히, 사용자 자신의 chat_user 질문까지 벗겨 냅니다. 커밋 본문은 changelog보다 정확합니다: "PDF export keeps only transcripts and screenshots, no chat messages." 내보낸 기록은 이제 순수하게 회의 — 무엇이 말해졌고 무엇이 화면에 있었는가 — 이고, 어시스턴트 사이드 패널 대화 전체가 거기서 제거되었습니다.
그리고 그것은 제품으로서 옳은 판단입니다. GeekBye의 채팅은 통화 도중에 당신이 상의하는 사적인 어시스턴트입니다 — 스크린샷에 대해 물어 재빠른 설명을 얻는다. 그 주고받음은 당신의 메모지이지, 회의의 일부가 아닙니다. 누군가와 공유할 회의 기록은 회의를 담아야지, 그것에 대한 AI와의 곁가지 대화를 담아서는 안 됩니다. 허용 목록은 그저 "회의"를 내보내지는 것의 글자 그대로의 정의로 만들 뿐입니다.
이 릴리스가 우리에게 가르쳐 준 세 가지
- 브라우저는 당신이 이미 가진 PDF 렌더러다. 문서가 구조화된 텍스트와 이미지라면, 숨긴
BrowserWindow위의printToPDF는 진짜 페이지 나눔, 진짜 폰트 렌더링, 그리고 CSS 레이아웃을 공짜로 줍니다 — PDF 그리기 API가 없어도. 당신은 라이브러리 의존을 브라우저 엣지 케이스와 맞바꾸는 것이고, 그건 흔히 더 나은 거래입니다. - 당신이 볼 수 있는 버그와 존재하는 버그가 늘 같은 것은 아니다. 「빈 상자」는 _빠진 데이터_를 외쳤지만; 원인은 _쪼개진 컨테이너_였습니다. 단정하기 전에 계측하라 — 팀의 디버그 로깅이야말로 그럴듯하지만 틀린 해결책(빈 것을 필터)을 진짜 것(
break-inside: avoid)으로 바꾼 것입니다. - changelog는 요약이고; 필터가 진실이다. "Excludes AI responses"는 당신 자신의 채팅 질문까지 떨어뜨리는 허용 목록입니다. 릴리스 노트가 동작 변경을 기술할 때, diff가 그 정확한 가장자리를 당신에게 말해 줍니다 — 그리고 여기서 정확한 가장자리는 "회의만 살아남는다"입니다.
v1 이야기의 이전 장은 OCR에서 픽셀로, 폴백을 잃지 않으면서(v1.8.6–v1.8.7)를; 그리고 전체 궤적은 소프트웨어를 완벽하게 출시하는 것의 해부학을 보세요.