
พิมพ์การประชุมเป็น PDF โดยไม่มี PDF library
GeekBye export การประชุมเป็น PDF ทั้งที่ในโค้ดไม่มี PDF library อยู่ที่ไหนเลย มัน render HTML ในหน้าต่าง browser ที่มองไม่เห็นแล้วพิมพ์มันออกมา ทางเลือกนั้นแหละคือทั้งเรื่อง: มันทำให้ฟีเจอร์นี้สร้างง่าย และมอบทุกความล้มเหลวที่ browser จริง ๆ มีให้มัน — แฟลชขาว, ลิมิตความยาว URL, และ page break ที่ตัด screenshot ขาดครึ่ง ทางแก้ของ bug ที่น่าเกลียดที่สุดคือ CSS หนึ่งบรรทัด
ในปุ่ม «export การประชุมนี้เป็น PDF» ของ GeekBye มีทริกที่น่าพอใจซ่อนอยู่: เบื้องหลังมันไม่มี PDF library เลย ไม่มี jsPDF, ไม่มี pdfmake, ไม่มี pdfkit, ไม่มี puppeteer แบบ headless ทั้งฟีเจอร์คือ browser ที่ซ่อนไว้ตัวหนึ่ง แอปสร้างหน้า HTML ที่พรรณนาการประชุม — entry ของ transcript, screenshot, timestamp — โหลดมันเข้าหน้าต่าง Electron ที่มองไม่เห็น แล้วขอให้หน้าต่างนั้นพิมพ์ตัวเองเป็น PDF ที่เหลือ webContents.printToPDF จัดการเอง ทางเลือกทางสถาปัตยกรรมเดียวนั้นแหละคือทั้งเรื่องของรีลีสนี้ เพราะมันทำให้ฟีเจอร์สร้างได้ถูก และยื่นทุก bug ที่ browser จริงมีให้มัน
«ก่อนหน้า» ก็คือรีลีสเดียวกัน
ปกติโพสต์แบบนี้เปิดด้วย «นี่คือวิธีที่มันทำงานเมื่อก่อน» คราวนี้ไม่มีเมื่อก่อน changelog ของ v1.8.9 อ่านเหมือนงาน maintenance — "fixed page breaks splitting screenshots and filtered empty entries" — ราวกับว่า PDF export เป็นฟีเจอร์เดิมที่กำลังโดน patch มันไม่ใช่ commit แรกที่แนะนำมันเข้ามา, feat: add PDF export via Electron printToPDF, ลงจอดในวันที่สามของ release cycle เดียวกัน และทุก «fix» ใน changelog คือการวนแก้ในสัปดาห์เดียวกันบนโค้ดที่เพิ่งเกิดมาไม่กี่ชั่วโมง ฟีเจอร์นี้ถือกำเนิด, พัง, และถูกทำให้แข็งแรง ทั้งหมดภายในแท็กเดียว ที่พูดออกมาดัง ๆ ก็เพราะนั่นคือรูปร่างที่ซื่อสัตย์ของการ ship หลาย ๆ ครั้ง: bullet ที่ขัดเงาใน release notes กับการถือกำเนิดอันรุงรังของสิ่งนั้น บ่อยครั้งเป็นรีลีสเดียวกัน
ทำไมต้อง browser ที่ซ่อนไว้ตั้งแต่แรก
กลไกนี้ควรเข้าใจก่อนถึง bug เพราะ bug ทั้งหมดสืบทอดมาจากมัน ทุกอย่างอยู่ในไฟล์ main-process ไฟล์เดียว, electron/ipcHandlers/pdfExportHandler.ts เมื่อ renderer ยิง IPC 'pdf:export-report', handler จะ:
- เรียก
buildReportHtml()เพื่อประกอบ HTML string เต็ม ๆ จาก timeline ของการประชุม — หนึ่ง.itemblock ต่อหนึ่ง entry ของ transcript หรือ screenshot - เขียน 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 library บังคับให้คุณวางตำแหน่งทุกอย่างด้วยมือ — วาด text ที่ (x, y), วัดมัน, เลื่อน cursor, จัดการ page break เอง browser ทำทั้งหมดนั้นให้อยู่แล้ว: คุณเขียน <div> กับ CSS แล้ว layout engine ของ Chromium ก็แบ่งหน้าให้คุณ คุณไม่ได้กำลังเรียน drawing API; คุณกำลังเขียนหน้าเว็บ สำหรับเอกสารที่ส่วนใหญ่เป็น text ที่จัดสไตล์และภาพที่ฝังไว้ นั่นคือทางลัดมหึมา
จุดที่ต้องระวังคือคุณก็รับมรดก browser มาด้วย พร้อมทุก edge case ของมัน สามในนั้นโผล่มาภายในสี่วันแรก
แฟลชขาว หน้าต่างที่ซ่อนไว้บน macOS ไม่ได้ซ่อนอย่างน่าเชื่อถือ ความพยายามแรกวางมันไว้นอกจอ (x: screenWidth + 1000) แล้วมันก็ยังแฟลชสี่เหลี่ยมขาวขึ้นจอหนึ่งเฟรมก่อนพิมพ์ ทางแก้ หลังจากสามครั้ง คือเลิกพึ่งตำแหน่งหรือ show: false แล้วตั้ง opacity: 0 — หน้าต่างที่โปร่งใสสนิทไม่แฟลช แม้ compositor จะแสดงมันแวบหนึ่งก็ตาม commit พูดตรง ๆ ว่า: "show:false with off-screen positioning still flashes on macOS."
ลิมิตความยาว URL เวอร์ชันแรก inline HTML ทั้งก้อน — screenshot และทุกอย่าง — เป็น data: URL นั่นใช้ได้จนกว่าการประชุมจะมี screenshot ที่ encode เป็น base64 อยู่ไม่กี่รูป ณ จุดนั้น URL ก็แล่นทะลุลิมิต data-URL ~2 MB ของ Chromium แล้วการโหลดล้มเหลวไปเลยด้วย ERR_INVALID_URL นั่นคือเหตุผลที่ขั้นตอนที่ 2 ข้างบนเขียน ไฟล์ ชั่วคราวแล้วทำ loadFile() แทนการโหลด data URL: path ของไฟล์ไม่มีลิมิตความยาว (~2 MB นั่นเป็นตัวเลขเดียวในทั้งเรื่องนี้ และมันเป็นการประมาณแบบ inline ในคอมเมนต์โค้ด ไม่ใช่สิ่งที่ใครไป benchmark มา — ถือว่ามัน «ใหญ่พอที่จะชน» ไม่ใช่ threshold ที่แม่นยำ)
text ที่ไม่ได้ escape transcript text เป็น user content ตามอำเภอใจ และมันถูกหย่อนลงไปใน HTML string ตรง ๆ transcript อันไหนที่บังเอิญมีอะไรอย่าง <div> หรือ <script> ก็จะทำหน้าพัง หรือแย่กว่านั้น ดังนั้น handler จึงงอกการทำ escapeHtml() หนึ่งรอบครอบทุก string ที่มัน interpolate นาทีที่ PDF ของคุณเป็นหน้าเว็บ HTML injection ก็เป็นปัญหาของคุณด้วยเช่นกัน
กล่องว่างที่ไม่ได้ว่าง
ทีนี้ถึง bug พาดหัว เพราะมันเป็นอันที่ดีที่สุด ผู้ใช้รายงาน PDF ที่ export ออกมามี กล่องว่าง — entry ที่แสดง timestamp กับ badge แต่ไม่มี screenshot มีแค่พื้นที่ว่างตรงที่ภาพควรอยู่ การอ่านที่เห็นชัดคือ «การ export กำลังทิ้งข้อมูลภาพ» และการแก้ที่เห็นชัดคือ «filter entry ว่างออก» ทีมได้เพิ่ม filter นั้นจริง ๆ — เดี๋ยวจะพูดถึงมันอีกที — แต่มันไม่ใช่สาเหตุจริง
สาเหตุจริงคือการแบ่งหน้า แต่ละ entry คือ .item container ที่อุ้ม timestamp, badge, และภาพหนึ่งภาพ Chromium ตอนพิมพ์เป็น PDF ยินดีที่จะตัดหน้า กลาง item — render timestamp กับ badge ไว้ล่างสุดของหน้าหนึ่ง แล้วดันภาพไปหน้าถัดไป สิ่งที่ดูเหมือนกล่องว่างคือครึ่งบนของ item ที่ภาพของมันล้นไปหน้าถัดมา ข้อมูลอยู่ครบ; layout ต่างหากที่เอามันขึ้นกิโยติน commit พูดตรงไปตรงมาเรื่องการวินิจฉัยผิดนี้: "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 บอก layout engine ให้เก็บ item ไว้ทั้งอัน — ถ้ามันไม่พอดีกับพื้นที่ที่เหลือบนหน้า ให้ย้ายทั้ง item ไปหน้าถัดไปแทนที่จะตัดมัน นี่คือผลตอบแทนของสถาปัตยกรรม browser-ซ่อนที่กล่าวไว้ในภาพย่อ: bug ที่น่าเกลียดที่สุดและถูกรายงานมากที่สุดในฟีเจอร์นี้ ถูกแก้ไม่ใช่ด้วยเลขคณิตความสูงของหน้าและการเรียก addPage() ด้วยมือ แต่ด้วย CSS property เดียวที่ browser รู้วิธีเคารพมันอยู่แล้ว คุณไม่ได้เขียนตัวแบ่งหน้า; คุณขอตัวที่คุณรับมรดกมาให้ทำตัวดี ๆ
สองชั้นของการ filter ของที่ว่างจริง ๆ
มัน มี entry ที่ว่างจริง ๆ อยู่ด้วย — แค่ไม่ใช่พวกที่ทำให้เกิดกล่อง สำเนาว่างจากการกด Cmd+Enter รัว ๆ สองที, screenshot ที่ไม่มี preview, entry ของ transcript ที่เป็นแค่ whitespace พวกนี้ถูก filter และที่น่าสนใจคือมันถูก filter สองครั้ง ในสองชั้น:
- ใน handler,
buildReportHtml()การ์ดแต่ละ item —if (!item.content?.trim()) return ''สำหรับ text และสำหรับภาพconst safeSrc = item.preview?.startsWith('data:image/') ? item.preview : ''; if (!safeSrc) return ''— แล้ว.filter(Boolean)ก็ทิ้งของว่าง เช็คตัวสุดท้ายนั้นทำหน้าที่สองอย่าง: มันเป็นด่านความถูกต้อง และ เป็นเช็คความปลอดภัยเล็ก ๆ ว่ามีแต่ข้อมูลภาพจริง ๆ เท่านั้นที่ไปถึงหน้า - ที่ต้นทาง timeline, ใน renderer, ความว่างเดียวกันถูก filter ก่อนที่ item จะถูกเพิ่มเข้าไปเสียอีก —
if (!entry.text?.trim()) return,if (!screenshot.preview) return
รายละเอียดที่น่ารักในประวัติคือวิธีการ commit แรกเพิ่ม loop logger.debug('PDF timeline item', …) อย่างชัดเจนว่า "for debugging empty entries" พอ logging นั้นเผยว่าที่ว่างมาจากไหนกันแน่ — รวมถึง bug การ dedupe screenshot อีกตัวหนึ่งที่การ capture รัว ๆ ผลิตสำเนาว่าง — commit ที่ตามมาก็แก้ root cause ที่ต้นน้ำ และ ลบ debug logging ออก เพิ่มเครื่องมือวัดเพื่อหาสาเหตุ, แก้สาเหตุ, ลบเครื่องมือวัด bullet ใน changelog "filtered empty entries" บรรจุส่วนโค้งเพิ่ม-แล้ว-ลบทั้งหมดนั้นไว้อย่างเงียบ ๆ
v1.8.12: "excludes AI responses" อ่าน diff เถอะ
สามรีลีสถัดมา มีการเปลี่ยนอีกอย่าง — และมันเป็นบทเรียนที่ดีเรื่องการอ่านโค้ดแทน changelog โน้ต v1.8.12 บอกว่า PDF export ตอนนี้ "excludes AI responses for cleaner meeting records" จริง แต่ไม่ครบ
timeline ของการ export แท็กแต่ละ item ด้วย type: 'transcript' | 'chat_user' | 'chat_assistant' | 'screenshot' บรรทัดการประชุมที่พูดออกมาคือ transcript; คำถามที่คุณพิมพ์เข้าไปใน assistant panel คือ chat_user; คำตอบของ AI คือ chat_assistant การเปลี่ยนใน v1.8.12 เป็นการแก้เก้าบรรทัดในไฟล์ renderer ไฟล์เดียว และบรรทัดที่ทำงานคือ filter ที่วางไว้ก่อน payload ของการ export จะถูกสร้างพอดี:
timeline.filter((item) => item.type === 'transcript' || item.type === 'screenshot')
นั่นคือ allowlist ไม่ใช่ blocklist มันไม่ได้เอา item ที่เป็น chat_assistant ออก; มันเก็บแต่ transcript กับ screenshot และทิ้งอย่างอื่นทั้งหมด — ซึ่งแปลว่ามันลอกคำถาม chat_user ของผู้ใช้เองออกไปพร้อมกับคำตอบของ AI ตัว commit body แม่นกว่า changelog: "PDF export keeps only transcripts and screenshots, no chat messages." record ที่ export ออกมาตอนนี้เป็นการประชุมล้วน ๆ — อะไรที่ถูกพูดและอะไรที่อยู่บนจอ — โดยบทสนทนาใน side-panel ของ assistant ทั้งหมดถูกเอาออกไป
และนั่นคือการตัดสินใจที่ถูก ในเชิงผลิตภัณฑ์ แชทของ GeekBye เป็นผู้ช่วยส่วนตัวที่คุณปรึกษา ระหว่าง สาย — ถามมันเรื่อง screenshot, ได้คำอธิบายไว ๆ การแลกเปลี่ยนพวกนั้นคือกระดาษทดของคุณ ไม่ใช่ส่วนหนึ่งของการประชุม record การประชุมที่คุณจะแชร์ให้ใครควรมีการประชุม ไม่ใช่บทสนทนาข้างเคียงของคุณกับ AI เรื่องมัน allowlist แค่ทำให้ «การประชุม» เป็นนิยามตามตัวอักษรของสิ่งที่ถูก export
สามอย่างที่รีลีสนี้สอนเรา
- browser คือ PDF renderer ที่คุณมีอยู่แล้ว ถ้าเอกสารของคุณคือ text ที่มีโครงสร้างและภาพ,
printToPDFผ่านBrowserWindowที่ซ่อนไว้ ให้การแบ่งหน้าจริง, การ render font จริง, และ CSS layout ฟรี ๆ — ไม่ต้องมี drawing API ของ PDF คุณแลก dependency ของ library กับ edge case ของ browser ซึ่งบ่อยครั้งเป็นการแลกที่ดีกว่า - bug ที่คุณเห็นกับ bug ที่มีอยู่จริงไม่ได้เป็นอันเดียวกันเสมอ «กล่องว่าง» ตะโกนว่า ข้อมูลหาย; สาเหตุคือ container ที่ถูกตัด วัดก่อนจะเดา — debug logging ของทีมนั่นแหละที่พลิกการแก้ที่ดูสมเหตุสมผลแต่ผิด (filter ของว่าง) ให้เป็นอันจริง (
break-inside: avoid) - changelog คือบทสรุป; filter คือความจริง "Excludes AI responses" เป็น allowlist ที่ทิ้งคำถามแชทของคุณเองไปด้วย เมื่อ release note พรรณนาการเปลี่ยนพฤติกรรม diff จะบอกขอบเขตที่แน่นอนของมันให้คุณ — และตรงนี้ขอบที่แน่นอนคือ «มีแต่การประชุมที่รอด»
สำหรับบทก่อนหน้าในเรื่องยุค v1 ดูจาก OCR สู่ pixel โดยไม่ทิ้ง fallback (v1.8.6–v1.8.7); และสำหรับภาพรวมทั้งเส้นเรื่อง ดูกายวิภาคของการส่งมอบซอฟต์แวร์ให้สมบูรณ์แบบ