
In Một Cuộc Họp Ra PDF Mà Không Có Thư Viện PDF
GeekBye xuất một cuộc họp ra PDF, mà chẳng có một thư viện PDF nào ở bất cứ đâu trong code. Nó render HTML trong một cửa sổ trình duyệt vô hình rồi in nó ra. Lựa chọn đó chính là cả câu chuyện: nó làm cho tính năng dễ dựng và trao cho nó mọi kiểu hỏng mà một trình duyệt thật có — một cú lóe trắng, một giới hạn độ dài URL, và một chỗ ngắt trang cắt đôi các screenshot. Cách sửa cho con bug xấu xí nhất là một dòng CSS.
Có một mẹo thỏa mãn ẩn trong cái nút «xuất cuộc họp này ra PDF» của GeekBye: đằng sau nó chẳng có một thư viện PDF nào. Không jsPDF, không pdfmake, không pdfkit, không puppeteer headless. Toàn bộ tính năng là một trình duyệt ẩn. Ứng dụng dựng một trang HTML mô tả cuộc họp — các entry transcript, các screenshot, các timestamp — nạp nó vào một cửa sổ Electron vô hình, và yêu cầu cửa sổ đó tự in mình ra PDF. webContents.printToPDF lo phần còn lại. Cái lựa chọn kiến trúc duy nhất đó chính là cả câu chuyện của bản phát hành này, vì nó làm cho tính năng rẻ để dựng và trao cho nó mọi con bug mà một trình duyệt thật có.
Cái «trước đó» chính là cùng một bản phát hành
Thường thì những bài này mở đầu bằng «đây là cách nó hoạt động trước kia». Lần này không có trước kia. Changelog của v1.8.9 đọc như bảo trì — "fixed page breaks splitting screenshots and filtered empty entries" — như thể xuất PDF là một tính năng đã có đang được vá. Nó không phải. Commit đầu tiên giới thiệu nó, feat: add PDF export via Electron printToPDF, đáp xuống vào ngày thứ ba của cùng một chu kỳ phát hành, và mọi «fix» trong changelog là sự lặp lại cùng-tuần trên đoạn code mới vài giờ tuổi. Tính năng ra đời, vỡ, và được tôi cứng, tất cả bên trong một tag. Điều đó đáng nói to lên vì đó là hình hài trung thực của rất nhiều lần ship: cái gạch đầu dòng đã đánh bóng trong release notes và sự ra đời bừa bộn của chính thứ đó thường là cùng một bản phát hành.
Tại sao lại là một trình duyệt ẩn ngay từ đầu
Cơ chế đáng hiểu trước các bug, vì các bug đều bắt nguồn từ nó. Mọi thứ nằm trong một file main-process, electron/ipcHandlers/pdfExportHandler.ts. Khi renderer bắn IPC 'pdf:export-report', handler:
- Gọi
buildReportHtml()để lắp một chuỗi HTML đầy đủ từ timeline của cuộc họp — một block.itemcho mỗi entry transcript hoặc screenshot. - Ghi HTML đó ra một file tạm trong
os.tmpdir(). - Nạp file vào một cửa sổ ẩn —
new BrowserWindow({ show: false, opacity: 0, focusable: false, skipTaskbar: true }). - Chờ hình ảnh nạp xong, rồi gọi
win.webContents.printToPDF({ printBackground: true, margins: { … } }). - Lưu kết quả vào
app.getPath('downloads')với tênMeeting-Report-<timestamp>.pdf.
Sức hấp dẫn quá rõ. Một thư viện PDF bắt bạn định vị mọi thứ bằng tay — vẽ text tại (x, y), đo nó, đẩy con trỏ tới, tự quản lý các chỗ ngắt trang. Một trình duyệt đã làm tất cả những cái đó rồi: bạn viết các <div> và CSS, và bộ layout engine của Chromium phân trang giùm bạn. Bạn không đang học một drawing API; bạn đang viết một trang web. Với một tài liệu mà phần lớn là text có style và hình ảnh nhúng, đó là một lối tắt khổng lồ.
Cái bẫy là bạn cũng đã thừa hưởng một trình duyệt, với tất cả các trường hợp biên của nó. Ba trong số đó hiện ra trong bốn ngày đầu.
Cú lóe trắng. Một cửa sổ ẩn trên macOS không ẩn một cách đáng tin. Những lần thử đầu đặt nó ra ngoài màn hình (x: screenWidth + 1000), và nó vẫn lóe một hình chữ nhật trắng trên màn hình trong một frame trước khi in. Cách sửa, sau ba lần thử, là ngừng dựa vào vị trí hay show: false và đặt opacity: 0 — một cửa sổ hoàn toàn trong suốt không lóe ngay cả khi bộ compositor thoáng hiện nó ra. Commit nói thẳng: "show:false with off-screen positioning still flashes on macOS."
Giới hạn độ dài URL. Bản đầu nội tuyến toàn bộ HTML — screenshot và tất cả — thành một data: URL. Điều đó chạy được cho tới khi một cuộc họp có vài screenshot mã hóa base64 trong đó, tại điểm ấy URL vượt qua giới hạn data-URL ~2 MB của Chromium và việc nạp thất bại thẳng thừng với ERR_INVALID_URL. Đó là lý do bước 2 ở trên ghi một file tạm và làm loadFile() thay vì nạp một data URL: một đường dẫn file không có giới hạn độ dài. (Cái ~2 MB đó là con số duy nhất trong cả câu chuyện này, và nó là một ước lượng nội tuyến trong một comment code, không phải thứ ai đó đã benchmark — hãy coi nó là «đủ lớn để đụng phải», không phải một ngưỡng chính xác.)
Text chưa escape. Transcript text là nội dung người dùng tùy ý, và nó đang bị thả thẳng vào chuỗi HTML. Bất kỳ transcript nào tình cờ chứa thứ gì đó như <div> hay <script> đều sẽ làm hỏng trang hoặc tệ hơn. Nên handler mọc thêm một lượt escapeHtml() phủ lên mọi chuỗi nó nội suy. Cái khoảnh khắc PDF của bạn là một trang web, HTML injection cũng là vấn đề của bạn.
Những ô trống mà không hề trống
Giờ đến con bug tiêu điểm, vì nó là con hay nhất. Người dùng báo cáo các PDF đã xuất có các ô trống — các entry hiện một timestamp và một badge nhưng không có screenshot, chỉ là khoảng trắng ở chỗ hình ảnh lẽ ra phải nằm. Cách đọc hiển nhiên là «việc xuất đang đánh rơi dữ liệu hình ảnh», và cách sửa hiển nhiên là «lọc bỏ các entry trống». Đội có thêm bộ lọc đó — nói thêm về nó ngay sau đây — nhưng nó không phải nguyên nhân thật.
Nguyên nhân thật là sự phân trang. Mỗi entry là một .item container ôm một timestamp, một badge, và một hình ảnh. Chromium, khi in ra PDF, vui vẻ ngắt một trang ngay giữa một item — render timestamp và badge ở đáy một trang rồi đẩy hình ảnh sang trang kế. Cái trông giống một ô trống chính là nửa trên của một item mà hình ảnh của nó đã tràn sang trang tiếp theo. Dữ liệu vẫn ở đó cả; là layout đã đưa nó lên máy chém. Commit thẳng thắn về chuyện chẩn đoán sai: "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."
Cách sửa là một dòng CSS:
.item {
break-inside: avoid;
}
break-inside: avoid bảo bộ layout engine giữ nguyên một item — nếu nó không vừa vào chỗ còn lại trên một trang, hãy dời cả item sang trang kế thay vì cắt đôi nó. Đây là phần thưởng của kiến trúc trình-duyệt-ẩn nói ở dạng thu nhỏ: con bug xấu xí nhất, bị báo cáo nhiều nhất trong tính năng được sửa không phải bằng số học chiều cao trang và các lời gọi addPage() thủ công, mà bằng một thuộc tính CSS duy nhất mà trình duyệt vốn đã biết cách tôn trọng. Bạn không viết một bộ phân trang; bạn nhờ cái bạn thừa hưởng cư xử cho phải phép.
Hai tầng lọc cái-thật-sự-trống
Cũng có những entry trống thật — chỉ là không phải những cái gây ra các ô. Các bản sao trắng từ một cú Cmd+Enter kép nhanh, screenshot không có preview, các entry transcript chỉ toàn khoảng trắng. Những cái này bị lọc, và thú vị là chúng bị lọc hai lần, ở hai tầng:
- Trong handler,
buildReportHtml()canh gác mỗi item —if (!item.content?.trim()) return ''cho text, và với hình ảnhconst safeSrc = item.preview?.startsWith('data:image/') ? item.preview : ''; if (!safeSrc) return ''— rồi.filter(Boolean)bỏ các cái trống. Phép kiểm cuối cùng đó gánh hai vai: nó là một cổng hợp lệ và một phép kiểm an toàn nhỏ rằng chỉ dữ liệu hình ảnh thật mới từng chạm tới trang. - Ở nguồn timeline, trong renderer, cùng cái sự trống ấy bị lọc trước khi một item từng được thêm vào —
if (!entry.text?.trim()) return,if (!screenshot.preview) return.
Chi tiết đẹp trong lịch sử là phương pháp. Commit đầu tiên thêm một vòng lặp logger.debug('PDF timeline item', …) một cách minh nhiên "for debugging empty entries". Một khi logging đó phơi bày các khoảng trắng thật ra từ đâu đến — kể cả một bug khử-trùng-lặp-screenshot riêng biệt nơi các cú chụp nhanh sinh ra bản sao trắng — commit theo sau sửa nguyên nhân gốc ở thượng nguồn và gỡ debug logging đi. Thêm công cụ đo để tìm nguyên nhân, sửa nguyên nhân, xóa công cụ đo. Cái gạch đầu dòng changelog "filtered empty entries" lặng lẽ chứa trọn cái vòng cung thêm-rồi-gỡ đó.
v1.8.12: "excludes AI responses", hãy đọc diff
Ba bản phát hành sau, một thay đổi nữa — và nó là một bài học hay về việc đọc code thay vì changelog. Ghi chú v1.8.12 nói xuất PDF giờ "excludes AI responses for cleaner meeting records". Đúng, nhưng chưa đủ.
Timeline xuất gắn thẻ mỗi item bằng một type: 'transcript' | 'chat_user' | 'chat_assistant' | 'screenshot'. Một dòng cuộc họp được nói ra là một transcript; một câu hỏi bạn gõ vào panel trợ lý là một chat_user; câu trả lời của AI là một chat_assistant. Thay đổi v1.8.12 là một chỉnh sửa chín dòng vào một file renderer, và dòng cốt yếu là một bộ lọc đặt ngay trước khi payload xuất được dựng:
timeline.filter((item) => item.type === 'transcript' || item.type === 'screenshot')
Đó là một allowlist, không phải blocklist. Nó không loại bỏ các item chat_assistant; nó chỉ giữ transcript và screenshot và bỏ mọi thứ khác — nghĩa là nó lột đi các câu hỏi chat_user của chính người dùng cùng với các câu trả lời của AI. Phần thân commit chính xác hơn changelog: "PDF export keeps only transcripts and screenshots, no chat messages." Bản ghi được xuất giờ thuần túy là cuộc họp — cái gì đã được nói và cái gì đã ở trên màn hình — với toàn bộ cuộc trò chuyện ở side-panel trợ lý bị lấy ra khỏi nó.
Và đó là quyết định đúng, về mặt sản phẩm. Chat của GeekBye là một trợ lý riêng tư bạn tham vấn trong lúc một cuộc gọi — hỏi nó về một screenshot, nhận một lời giải thích nhanh. Những trao đổi ấy là tờ nháp của bạn, không phải một phần của cuộc họp. Một bản ghi cuộc họp bạn định chia sẻ cho ai đó nên chứa cuộc họp, không phải các cuộc trò chuyện bên lề của bạn với một AI về nó. Cái allowlist chỉ đơn giản biến «cuộc họp» thành định nghĩa theo nghĩa đen của cái được xuất ra.
Ba điều bản phát hành này dạy chúng tôi
- Một trình duyệt là một bộ render PDF bạn đã có sẵn. Nếu tài liệu của bạn là text có cấu trúc và hình ảnh,
printToPDFqua mộtBrowserWindowẩn cho bạn phân trang thật, render font thật, và CSS layout miễn phí — không cần drawing API của PDF. Bạn đổi một phụ thuộc thư viện lấy các trường hợp biên của trình duyệt, mà thường là cái đổi tốt hơn. - Con bug bạn thấy được và con bug tồn tại không phải lúc nào cũng là một. «Các ô trống» gào lên dữ liệu mất; nguyên nhân lại là một container bị cắt. Hãy đo trước khi bạn giả định — chính debug logging của đội đã biến một cách sửa hợp-lý-nhưng-sai (lọc cái trống) thành cái thật (
break-inside: avoid). - Changelog là một bản tóm tắt; bộ lọc là sự thật. "Excludes AI responses" là một allowlist mà cũng bỏ luôn các câu hỏi chat của chính bạn. Khi một release note mô tả một thay đổi hành vi, diff sẽ cho bạn biết các cạnh chính xác của nó — và ở đây cạnh chính xác là «chỉ cuộc họp sống sót».
Về chương trước trong câu chuyện v1, xem Từ OCR Sang Pixel Mà Không Đánh Mất Fallback (v1.8.6–v1.8.7); và cho toàn bộ vòng cung, xem giải phẫu việc xuất xưởng phần mềm đến độ hoàn hảo.