對一般用戶來說,要把文件存成 PDF,大多是按個「匯出」、「另存新檔」、「列印」就完工的事,但在程式端搞起來可不簡單。

生成 PDF 的方式

在 web app 的典型情境不外乎把原本是 HTML 形式的文件、報表、票卡、收據、帳單轉換成 PDF,一般用幾種方式辦到:

用瀏覽器生成

前端有許多 PDF 生成套件可供選用,但問題是你無法保證用戶瀏覽器一致,可能會有版面、字體、顏色各方面參差問題。

在後端生成

以 Python 為例,常見的有 WeasyPrintPython-PDFKit

PDFKit 是以 wkhtmltopdf 為基礎的工具,而 wkhtmltopdf 則是以 Qt WebKit 為基礎的工具,這個 Qt WebKit 不知道是哪個年代的 WebKit,對當代的 CSS 支援極差,flexbox、grid 都不可用,不禁佩服以前共事的三寶之大寶還能用 wkhtmltopdf 生出有模有樣的標籤,給大寶一個讚!

WeasyPrint 是走自行解析 HTML 的路子,它的解析器來自其他 Python 套件,優點是輕量、快速,問題是它的解析器終究不比真正的瀏覽器,容錯、標準支援方面都不夠好,例如下面這些問題:

  • 支援 CSS flexbox ,但對 CSS grid 完全不支援。
  • 支援 justify-contentalign-items,但對 place-contentplace-items 都不支援。

兩者相較還是 WeasyPrint 要好得多,而且它的範例製作的挺精美,令人有想用的衝動。

在後端用無頭瀏覽器生成

這方面個人較偏好用 Playwright,雖然這有點大材小用了,這類方案的問題是都要裝肥大的瀏覽器,瀏覽器啟動需要時間,又是吃記憶體怪獸,對我的 286 主機是一大開銷,但在其他方案沒一個能打的情況下,看來是沒有選擇的選擇。

生成 PDF 的考量

HTML 轉 PDF 不只是另存新檔那麼簡單,還有這些考量:

  • 安全性,來源的 HTML 是哪邊來的,可靠嗎?有用戶輸入的內容嗎?有可能被注入攻擊嗎?
  • PDF 頁首頁尾有需要嗎?生成工具有支援嗎?
  • 紙張尺寸是標準 A4 嗎?如果是自訂尺寸,生成工具有支援嗎?
  • 生成工具有支援 CSS 換頁語句嗎?會需要跨頁表格標題列重複嗎?
  • 要生成的內容是複雜的 data table 嗎?後端組的出來嗎?
  • 後端無頭瀏覽器被大量調用會吃爆主機資源嗎?需要引入 queue 嗎?該即時回應嗎?

CSS 實操

在 CSS 方面,可以用某些屬性設定列印樣式:

@page {
    size: 11.3cm 4.3cm;
    margin: 0;
}

@media print {
    body, div {
        outline-width: 0px !important;
    }
}

hr#page-breaker {
    break-after: page;
    height: 0;
    border-width: 0;
}

說明如下:

  • @page 區塊負責設定頁面尺寸、邊距。
  • @media print 區塊用於設定其他列印時的樣式。
  • break-after: page 設定強制換頁屬性,上面設定了一個高度為零的換頁元素。

WeasyPrint 實操

直接上碼:

import weasyprint

def html_to_pdf(html_str: str):
    html = weasyprint.HTML(string=html_str)
    pdf_bytes: bytes = html.write_pdf()
    return pdf_bytes

html_raw = template.render()
pdf_bytes = html_to_pdf(html_raw)

上面的 template.render() 實際上是 Jinja2 的 HTML 生成函式,在這裡不深究,總之就是來個 HTML 字串。

後面用 weasyprint.HTML() 則是把 HTML 字串解析成它自己的 HTML 物件,再生成 PDF 回應出去。

Playwright 實操

一樣直接上碼:

from playwright.sync_api import sync_playwright

def html_to_pdf(html_str: str):
    pdf_bytes: bytes
    
    with sync_playwright() as p:
        browser = p.chromium.launch()
        page = browser.new_page()
        page.set_content(html=html_str)
        pdf_bytes = page.pdf(
            height='4.3cm',
            margin={
                'top': '0',
                'right': '0',
                'bottom': '0',
                'left': '0'
            },
            width='11.3cm',
        )
        browser.close()

    return pdf_bytes

html_raw = template.render()
pdf_bytes = html_to_pdf(html_raw)

Playwright 因為要開瀏覽器,所以用 with 區塊管理瀏覽器的生命週期,事情做完就關閉。

相較於 WeasyPrint 是直接解析 HTML 轉成 PDF,Playwright 是列印的概念,所以還得在此設定列印的參數,主要也是紙張的尺寸和列印的邊距。

Windows、異步環境下的 Playwright 問題

在 Playwright 網站上寫著:

Incompatible with SelectorEventLoop of asyncio on Windows

很不巧的 Jupyter 或 uvicorn 都是在 asyncio 下跑的,在他們支配的環境下,不論是 sync_playwrightasync_playwright、不論是 SelectorEventLoopProactorEventLoop,跑起 Playwright 都會遇到 NotImplementedError,只好放大招開另一支 process 處理。

動用 multiprocessing 後的修改如下:

import multiprocessing as mp
from playwright.sync_api import sync_playwright

def html_to_pdf(q: mp.Queue, html_str: str):
    pdf_bytes: bytes
    
    with sync_playwright() as p:
        browser = p.chromium.launch()
        page = browser.new_page()
        page.set_content(html=html_str)
        pdf_bytes = page.pdf(
            height='4.3cm',
            margin={
                'top': '0',
                'right': '0',
                'bottom': '0',
                'left': '0'
            },
            width='11.3cm',
        )
        browser.close()
    q.put(obj=pdf_bytes)

html_raw = template.render()

q = mp.Queue()
process = mp.Process(target=html_to_pdf, args=(q, html_raw,))
process.start()
pdf_bytes:bytes = q.get()
process.join()

file_like = BytesIO(initial_bytes=pdf_bytes)

其中:

  • qQueue() 物件,用於跨 process 通訊,一邊用 q.put() 丟出結果,另一邊用 q.get() 接收結果。
  • process 負責把 Playwright 跑起來,start() 語意很明確,就是把 process 跑起來,但最後的 join() 是什麼意思?它並沒有「join」任何東西,不要從語意的角度理解它,它的作用是確認子 process 結束,讓主 process 繼續往下走,類似於異步環境下的 await

結語

實際操練下來,WeasyPrint 和 Playwright 比較可靠,WeasyPrint 雖然有 CSS 支援上的弱勢,但還是可以用 flexbox 組出可用的版面,又不像 Playwright 那麼吃資源,反之 Playwright 有較佳的 CSS 支援,但相對的生成速度真的比較慢一點,兩者的取捨個人也尚無定論,至於那還是有很多文章提到的 wkhtmltopdf,就忘了它吧。