Steven
Steven2 分で読める

PDFライブラリなしで会議をPDFに印刷する

GeekByeは会議をPDFとして書き出しますが、コードのどこにもPDFライブラリはありません。目に見えないブラウザウィンドウでHTMLをレンダリングし、それを印刷するのです。その選択がこの物語のすべてです: それは機能を作りやすくし、同時に本物のブラウザが持つあらゆる故障を与えました — 白いフラッシュ、URLの長さ制限、そしてスクリーンショットを半分に切ったページ区切り。最も醜いバグの修正は、CSS一行でした。

エンジニアリング
Electron
デスクトップ
GeekByeリリース
PDFライブラリなしで会議をPDFに印刷する

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を発火させると、ハンドラーは:

  1. buildReportHtml()を呼び、会議のタイムラインから完全なHTML文字列を組み立てます — 文字起こしの項目またはスクリーンショットごとに一つの.itemブロック。
  2. そのHTMLをos.tmpdir()内の一時ファイルへ書き出します。
  3. そのファイルを隠しウィンドウへ読み込みます — new BrowserWindow({ show: false, opacity: 0, focusable: false, skipTaskbar: true })
  4. 画像の読み込みを待ってから、win.webContents.printToPDF({ printBackground: true, margins: { … } })を呼びます。
  5. 結果を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()) returnif (!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の項目を取り除くのではありません; transcriptscreenshotだけを保ち、他のすべてを落とします — つまりそれは、AIの答えと並んで、ユーザー自身のchat_userの質問をも剥ぎ取るのです。コミット本文はchangelogより正確です: "PDF export keeps only transcripts and screenshots, no chat messages." 書き出される記録はいまや純粋に会議 — 何が言われ、何が画面に映っていたか — であり、アシスタントのサイドパネルの会話まるごとがそこから取り除かれています。

そしてそれは、プロダクトとして正しい判断です。GeekByeのチャットは、あなたが通話の_最中に_相談する私的なアシスタントです — スクリーンショットについて尋ね、手早い説明を得る。そのやり取りはあなたの走り書きであって、会議の一部ではありません。誰かと共有する会議の記録は、会議を含むべきであって、それについてのAIとの脇の会話ではありません。許可リストはただ、「会議」を、書き出されるものの文字どおりの定義にしているのです。

このリリースが教えてくれた三つのこと

  1. ブラウザは、あなたがすでに持っているPDFレンダラーです。 文書が構造化されたテキストと画像なら、隠したBrowserWindow越しのprintToPDFは、本物のページ分割、本物のフォント描画、そしてCSSレイアウトを無料で与えてくれます — PDFの描画APIは要りません。あなたはライブラリの依存をブラウザのエッジケースと引き換えにするのであり、それはしばしば良い取引です。
  2. あなたが見られるバグと、実在するバグとは、いつも同じとは限りません。 「空の箱」は_データの欠落_を叫んでいた; 原因は_分割されたコンテナ_でした。決めつける前に計器を当てよ — チームのデバッグロギングこそが、もっともらしいが間違った修正(空をフィルターする)を、本当の修正(break-inside: avoid)へと変えたものです。
  3. changelogは要約であり; フィルターが真実です。 "Excludes AI responses"は、あなた自身のチャットの質問をも落とす許可リストです。リリースノートが挙動の変更を記述するとき、diffがあなたにその正確な縁を告げます — そしてここでの正確な縁は「会議だけが生き残る」です。

v1の物語の前の章については、OCRからピクセルへ、フォールバックを失わずに(v1.8.6–v1.8.7)を; そして弧の全体については、ソフトウェアを完璧に出荷することの解剖学をご覧ください。

関連記事

クリックスルー・オーバーレイの二つの故障モード
Steven
Steven1 分で読める

クリックスルー・オーバーレイの二つの故障モード

GeekByeのウィンドウはすべての上に浮かび、ボタンのある場所を除いて、あなたのクリックをそのまま通り抜けさせます。それは二面的な契約であり、v1.8.5とv1.8.14は、その各面が壊れたときにどう見えるかを示します: 一方のリリースではオーバーレイがシステムダイアログを飲み込み、もう一方ではあなたのキー入力を奪いました。二つ目の勝利をもたらした修正は、コードを削除することでした。

エンジニアリング
Electron
デスクトップ
十二分で生まれ、そして直された
Steven
Steven2 分で読める

十二分で生まれ、そして直された

GeekBye v1.8.10のchangelogは、キーボードショートカットの編集中のクラッシュを直したと言います。直したのは本当です — けれどそのクラッシュは同じプルリクエストの中で、十二分の間を置いて、導入され、そして直され、ただの一人のユーザーにも届きませんでした。本当の物語は、それを生んだ信頼性の連鎖です: ショートカットをいつ一時停止するかへの、小さく正しい変更と、エディターをより安全にするための一行から転がり落ちたReactのティアダウンのバグ。

エンジニアリング
React
デスクトップ
OCRからピクセルへ、フォールバックを失わずに
Steven
Steven1 分で読める

OCRからピクセルへ、フォールバックを失わずに

GeekByeはスクリーンショットをOCRで読むのをやめ、実際の画像をvision対応モデルへ送り始めました — そこは簡単な部分です。学ぶべきなのは、vision利用者に対してOCRを丸ごと省いた最適化のほうです。それはvision非対応モデルへのフォールバックを静かに壊し、セーフティネットが並行して走り何のコストもかからないバージョンへと差し戻さねばなりませんでした。

エンジニアリング
AI
デスクトップ