こんにちは。
テックリードのTerukiです。
本日はPDF作成です。
いろいろ実装に悩みましたが、Oh my teethではこうしたよという話ができればなと。
.NETでPDF作成
業務でPDFを作りたくなる場面はたまに出てくると思います。
.NETで実現するために調べるとまず最初に出てくるのはiTextなどの有償ライブラリだと思います。
AGPLでもOKであれば良いかもですが、PDFの作成がプロダクトのコアとなるわけではないような用途の場合ここに多額のライセンス料を支払う意思決定をするのはとても厳しいです。
コピーレフトなライセンスではないPDFを編集できるOSSとして、PdfSharpというものがあります。
こちらはMIT Licenseなので商用利用も可能で、一定のPDF操作ができるため便利です。
執筆時点でpreview版の6.2.0では署名を付与するインターフェースも追加されているのでそういった用途でも使えそうです。(preview版を許容できる場合)
ただ、凝ったデザインのPDFを作成したいと思った時に有償ライブラリやPdfSharpなどで作り込むと後から修正するのが非常に大変になることが予想されます。
だとしたらWebベースで画面を作ってしまってそれをPDFに出力してしまう方式のほうが開発者ツールの豊富さも鑑みて優位性がありそうです。
というわけでOh my teethではPDFを出力する方法としてChromiumをバックエンドに抱えて出力する方式にすることにしました。
ChromeDriverをLambdaで動かす
マイクロサービスっぽくしたかったのでAWSのLambdaで動かすようにしました。
LambdaにHTML本文をPOSTするとそれをレンダリングしたPDFをレスポンスとして返してくれるというイメージです。
var html = (string?) input["body"]; // Lambdaの入力
if (html is null) {
return null;
}
// Lambdaで動かすために必要
var option = new ChromeOptions();
option.AddArgument("--headless=new");
option.AddArgument("--no-sandbox");
option.AddArgument("--disable-gpu");
option.AddArgument("--single-process");
option.AddArgument("--disable-dev-shm-usage");
option.AddArgument("--no-zygote");
option.AddArgument("--user-data-dir=/tmp/userdata");
option.AddArgument("--data-path=/tmp/data");
option.AddArgument("--disk-cache-dir=/tmp/cache");
option.AddArgument("--remote-debugging-port=9222");
using var driver = new ChromeDriver(option);
await driver.Navigate().GoToUrlAsync("data:text/html;base64;charset=utf-8," + Convert.ToBase64String(Encoding.UTF8.GetBytes(html)));
var wait = new WebDriverWait(driver, TimeSpan.FromSeconds(10));
wait.Until(d => ((IJavaScriptExecutor)d).ExecuteScript("return document.readyState").Equals("complete"));
// 必要に応じて変更
await Task.Delay(100).ConfigureAwait(false);
var result = driver.ExecuteCdpCommand("Page.printToPDF", []) as Dictionary<string, object>;
var base64PdfBinary = (string)result!["data"];
いろいろとオプションを付けていますが、つけないとLambdaの処理時間がめちゃくちゃ長くなります。
やっていることはシンプルで、data URLでHTMLをレンダリングしているだけです。
Webフォントの読み込みが非同期で行われるのでDelayを入れていますが、クライアントサイド側で検知するコードを書けるのならそのほうが合理的な感じはします。
Page.printToPDFというChrome DevTools Protocolのコマンドを使ってPDFを出力させています。
コード的にはこれだけでPDFの作成ができます。
めちゃくちゃお手軽ですよね。
Labmdaで動かす場合
Lambdaで動かす場合はDockerfileを工夫する必要があります。
私が書いたものはこちら。
FROM mcr.microsoft.com/dotnet/runtime:9.0 AS base
WORKDIR /app
# chromedriverとchromeを動かすのに必要
RUN apt-get update && apt-get install -y libglib2.0-0 libnss3 libx11-xcb1 libdbus-1-dev libatk-bridge2.0.0 \
libcups2 libdrm2 libxcomposite1 libxfixes3 libxrandr2 libgbm1 libxkbcommon0 libpango-1.0-0 \
libcairo2 libasound2 libxdamage1
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
WORKDIR /src
COPY ["PDFGenerator.csproj", "."]
RUN dotnet restore "PDFGenerator.csproj"
COPY . .
RUN dotnet build "PDFGenerator.csproj" -c Release -o /app/build
FROM build AS publish
RUN dotnet publish "PDFGenerator.csproj" -c Release -o /app/publish /p:UseAppHost=false
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish ./
# Lambdaはrootで動かないので権限を変える
RUN chmod a+x /app/selenium-manager/linux/selenium-manager
ENTRYPOINT ["dotnet", "PDFGenerator.dll"]
chromeやchromedriverのバイナリを動かすのに必要なパッケージが足りないので最初にインストールしてしまいます。
Lambdaはコンテナ内でrootではないユーザーが動くため、selenium-managerに実行権限を付けて上げないと失敗します。
なかなかトラブルシューティングに苦戦しました。。
これで動きます。
おわりに
ちょっと短いですが、.NETでこういうことをやっている記事はあんまりなさそうだったので書いてみました。
Seleniumの本来の使い方ではない気がしますが、これはこれで有りだなと個人的には思っています。
PdfSharpの記事などもどこかで別途書けたら良いなと思っています。
Oh my teethについて
Oh my teethでは未来の歯科体験を創るために日々活動しています。
Techチームではより良いユーザー体験を提供するべく、Webフロントエンドからバックエンド、スマホアプリに機械学習モデルなど、さまざまなプロダクトを開発しています。
一緒に未来の歯科体験を創りませんか?興味がある方は是非こちらを確認してください。
カジュアル面談も可能なので気軽に応募してみてください!