🐡

HTMLからPDFを作成するクラウドサービスを作りました

2024/12/05に公開

作ったサービスはこちらです。

まず、どんなものを作ったかを言えば、以下のようなものになります。
内容は表題からもわかるようにURLを指定すればそこからPDFを作るというものです。

ここでは、これを作った経緯と作る上でどんなところに苦労したか、工夫したか、などをエンジニア目線から記したいと思います。

https://quick2pdf.com

似たようなものとしてiLove PDFなどを思い浮かべる方もいるかもしれませんが、利用側としてPDFを作成するのではなく、システム内部に組込み提供側としてPDFを作成するものです。

https://www.ilovepdf.com/ja

なぜ作ったのか?

業務システムをつくっていると、「どうしてもここを紙に印刷できるようにしてほしい」や「関連企業等に提出するためPDFにて出力してほしい」という要望を受けます。

しかし、PDF帳票を本気で開発しようとすると結構大変。商用ライセンスのライブラリやパッケージを購入してサービスを使うという手もありますが、それでは過剰投資という場合もよくあります。
意外と高いので、導入する際に気が引けます。

そこで、まあまあの人が考える方法がこんな感じでしょう。

  • HTMLで印刷用ページを作るのでそれを利用者が印刷機能でPDFにしてもらう。
  • フリーなどのPDFライブラリを使って非常に簡易的に作る。

しかし、HTMLで印刷するというのは、エンジニアからしてみれば簡単だったりするのですが、IT知識に疎い人がいると受け入れてはもらえない場合があります。

一方、PDFライブラリを使ってPDFを作ろうとすると、簡単な表組み程度の帳票ならよいのですが、図も差し込みたい、フォントも指定したい、文字の自動改行もやってほしいとか、PDFの構造を知らない人から見れば簡単そうに見える事もPDFの仕様にそって開発するとなると大変なのです。

それにPDFライブラリを使う上で結構大変なのが、日本語フォントです。フォントデータをすべて埋め込むとデータは大きくなるし、使っている文字分だけのフォントデータを埋め込むという処理は事実上、プログラムではやっていられません。

かなり昔ですが、PHPでPDFを作る際にZendライブラリというものがあったのですが、これを使うと日本語の取り扱いが難しく、また、フリーのlibharuというエクステンションがあったのですが、ここでも日本語の問題があったので、日本語周りの問題を修正してPHPのライブラリ側にマージしてもらった事を思い出します。

せっかくなので、その後、こんな記事も書いてみました。

https://codezine.jp/article/detail/7141

思えば、その頃からPDFとはいろいろ格闘しています。

そこで何かいい方法はないかと考えつつ、やっぱり、HTMLからPDFに変換するのがもっとも効率的だろうという事で最初はオープンソースなどを駆使してやっていたのですが、いろいろなニーズや運用の苦労を解決していくうちに、大きく作り変える事になり、既存のお客さんの運用では問題もなくなったので、せっかくだからそれをサービスとして他の人にも使ってもらいたいと思い、リリースしました。

ただ作ってみてなんですが、ありそうなのに、ないサービスである理由が自分達でいろいろ困った部分を振り返って見るとわかるように思いました。

chrominumをサーバ上で動かす

HTMLをPDFに変換する為には、chromiumのエンジンで処理して、それをPDF出力すれば終了。
と言えば簡単なのですが、不特定多数の人が任意のタイミングで使う事を前提にすると思ったよりも苦労しました。

Chromeプロセスを毎回立ち上げる

まず、やったことはchromeのプロセスをリクエストの度に立ち上げる方法です。

chromeのドキュメントによれば
https://developer.chrome.com/blog/headless-chrome?hl=ja

以下のようなコマンドでPDFの作成が可能です。
chrome --headless --disable-gpu --print-to-pdf https://www.chromestatus.com/

つまり、これで機能としての要件は満たすことはできるのですが、これだと、PDF作成に失敗した時にどうして失敗したのかがわからないので、WEBシステム上のUIから作る上ではちょっと問題があります。

ただし、Electronなどを使ってもう少し細かく制御することも可能だとは思います。

一方、毎回ブラウザのプロセスをPDFの作成の度に作っていると、利用者が同時に処理を行うとCPUやメモリなどリソース消費も大きく、また、作成までの時間がどうしてもかかるため、PDFリクエストのたびにchromiumのプロセスの起動ではなく、タブを開いて処理をするようにしています。

どうしてもブラウザが固まる・・・

実運用していると、ブラウザがどうしても固まってしまう事がありました。
その原因として以下ようなものがありました。

  • ページ数が多すぎるPDFを作成している
  • 長時間稼働していると原因不明で落ちる or 反応がなくなる

DBから取得したデータを元に、自動で改ページしてPDFを作成するようにしていたのですが、ある一人の利用によりPDFが作成出来ないというクレームが入りました。

そして、状況を聞くと1000ページ以上にもなるPDFを出力していました。さすがに、1000ページものPDFを一回で作ろうとするとリソースが枯渇するようで固まってしまうようです。
そして、その人がその処理をすると全員に影響するという問題が発生しました。
これは、数ページずつPDFを作成し、それをマージするという方法ができるようにしました。
(ただし、この機能はまだサービス上では使えるようにしていません。)

そして、もう一つ、数ヶ月に1回程度、プロセスを立上げたままにしているとchromiumが反応しなくなります。完全にプロセスが落ちてくれればまだ自動起動などもできるのですが、プロセスが残ったまま、固まってしまうケースがあります。

この問題への対応には本当に苦労しました。
chromiumプロセスの内部にWebSocketクライアントを実装し、そのWebSocket通信に反応がなくなれば、そのプロセスは状態不明として利用禁止のプロセスとしてマークし、そのプロセスを外部から強制的にkillするということをしています。
もちろん、その間、他のリクエスト処理ができないと困るので、chromiumプロセスプールがあるという感じです。

結局、こうやってリアルタイムに状態を監視し、正常なプロセスのchromiumにリクエストを振り分けるようにするロードバランサまで実装することになってしまいましたが。

HTML(Webコンテンツ)はいつPDF作成してよい?

HTMLをサーバ側ですべて作成していれば、すべてのHTMLが読込完了したタイミングでPDFを作成すればよいのですが、VueJSやReactなどを使ったページだと、それらのプログラムの実行処理が終わったタイミングを待たなければいけません。

処理が終わったことを自動で判別するには

「処理が終わったとは」を抽象的に言えば、「必要な通信が終わって、そのちょっとだけ後だろう」という事だと思います。

それをどう定義するか?またはそれをどうやって判断するか?です。
結論を言えば、下図のように、画像、CSS、XHRやFetchなどのリクエストが何もなくなってから、0.5秒後というようにしました。

自動での判別方法

あくまで、レスポンスが返ってきて、もう、新たなリクエストがないという状態で0.5秒のアイドルが経過したというタイミングです。
つまり、もし、JavaScriptの実行に0.5秒以上かかったら、未完成の表示状態でPDFが作成される可能性があります。
実際にはそこから印刷処理(PDF作成)に入るという事なのですが、必ず0.5秒以上かかっていたら結果的にダメという事もないのですが、保証は出来ない処理になっています。

自動で識別できないケースへの対応

最初はWEBコンテンツの処理が終わったタイミングでDOMイメージを使ってPDFを作ろうと考えた事もあり、Prerender.IOというオープンソースを検討した時期があったのですが、そこでのアイデアを借りて、window.prerenderReady という変数があればこれを見るようにしました。

https://github.com/prerender/prerender

これ以外にももう少し細かく制御できるようにしたAPIもあるのですが、このようにHTML側のJavaScriptの変数や関数なども使って判別出来るようにしました。

※DOMイメージのHTMLだとCanvasの中身を見ることができないし、CSSをサポートするのも大変なので、この構想はすぐになくなりました。

通信が途絶えないHTMLへの対応

通信が途絶えないHTMLとは、常に何らかの通信をしているページです。本来、PDFを作成する為のHTMLに必要がないはずの、行動追跡や広告用のAPIが組み込まれているケースがありました。
(通信をはったままの状態で監視する仕組みのようでした)

お客さんとしては、サイトの全ページに影響させたいと思っているので共通部分に差し込んでいるとの事ですが、これをやられると前述した自動判別ができません。

これも、手動で判別出来るようにした理由の一つです。

また、ホワイトリスト形式で広告用のAPIのリクエストはブロックするようにしています。
なので、広告APIを使ってコンテンツを差し込んだPDFを作りたいという場合、正しく出来ない場合がありますので、その場合には相談ください。

Electron-PDFというのを使えば、タイミングは指定できるらしい

当時はこのようなソリューションは見つけられなかったのですが、やはり同じようなタイミングの問題は共通としてあるようです。

https://www.npmjs.com/package/electron-pdf

実際にどんな感じのAPIでPDFの作成が可能か

APIをどう設計するか?というのは結構難しい問題です。簡単なほどいいのですが、一方で簡単なほどセキュリティが甘くなりがちになります。

また、実際に運用を始めて何からの原因不明のエラーがあったときに、途中がわかると助かりますのでその辺りのバランスを結構悩みました。

JavaScriptのみで作成する

必要なJavaScriptライブラリの読込がありますが、以下のコードだけでPDFが作成できます。
ただし、リクエスト先のURLのスキーマとドメイン、出力するPDFのサイズなどの設定はコード上からは出来ず、利用者コンソール画面で設定する事になります。

window.Quick2PDF.start('/sample4',{
    key : "APIキーを設定"
});

サーバ上で作成する

サーバからリクエストする場合には、以下のようにまずリクエストするためのハッシュキーを作成しBase64でエンコードします。

echo -n '{"state":1733380130622,"url":"\/sample4"}' |  \
	openssl dgst  \
		-sha256 \
		-hmac [APIキー] -binary | \
	base64

そして、こんな感じでリクエストすればPDFがダウンロードできます。

curl -v -X POST -o output.pdf \
	-H 'Content-Type: application/json' \
	-H 'X-Quick2PDF-Signature: [作成されたシグネチャーキー]' \
	-d '{"state":1733380130622,"url":"\/sample4"}' \
	https://api.quick2pdf.com/sr/v1/[APIキー]

途中経過を把握する

先ほどのリクエストのリクエストヘッダのAcceptを「text/event-stream」として指定すると途中経過もわかるようになっています。
ちなみに、PDFデータはダウンロードURLがデータとして返りますので、それを使います。

curl -v -X POST \
	-H 'Accept: text/event-stream' \

POST形式なので、これをブラウザから実行する事は難しいとは思います。

その場合には、リクエストをトークンを作成するAPIがありますので、いったんトークンを作って、そのトークンからブラウザ上でGETリクエスト出来るようにしています。

そうすれば、ブラウザのEventStreamを使って状況把握がしやすくなります。

第三者からURLへのアクセスを拒否したい

APIでURLをhttps://[ドメイン]のように指定しないのは、やはり、PDF作成のために使われるURLを利用者側からわかるようにしたくないためです。

なので、基本的にはスキーマ部分とドメイン部分は記述しないようにしています。(記述しても問題ありませんが、必ず利用者コンソール側の設定とあわせる必要があります。)

また、ドメイン部分を簡単に推測できてもアクセスできないように、任意のヘッダーをリクエスト時に追加できるようにしていますので、それで制御できるようにもしています。

また、Basic認証をかけたページでもアクセスできるようにしています。

インターネットからDNS解決できないURLにはアクセスできないのですが、どうしてもという場合には、Proxyサーバを用意してもらえれば

プラスした機能

自分達で確認した問題ないと分かっているHTMLをPDFにするということであれば、先ほどのElecrtron-PDFなどでも十分というケースもあると思います。
特に夜間バッチみたいな使い方であれば、これまで上げてきたような問題はおきにくいので、それでいいように思います。

まあ、そうなってしまうと少々寂しいので、以下の機能をつけました。

  • パスワード付PDFの作成
  • タイトルと印刷と編集のプロパティ設定
  • タイムスタンプ署名

タイムスタンプ署名はPDFの改変防止などをしたいのであれば有効だと思います。

残っている課題

残っている課題としては、「ブラウザキャッシュ」のクリアです。
ブラウザ側でコンテンツを「キャッシュ」するようにして、リクエスト時に毎回同じリソースを取得しないようにしているのですが、開発中やちょっとした間違いが分かった場合にそのキャッシュが邪魔になります。また、手元にブラウザがあるわけでもないためこのキャッシュが残るのが問題です。

出来るだけ、通信を減らして処理を軽くしたいので、バランスが難しい問題です。

現在、ステータスで「テスト(開発中)」と「本番」モードをつけて「テスト」ではいわゆる「プライベートモード」で起動しているので、そこではキャッシュ問題は比較的問題にならないと思いますが、「本番」モードはキャッシュが残ってしまうので、どうしてもキャッシュを無視するためにはURLを変える必要があります。
これは通常のWEBシステムでも同じようにある問題なので、どうような方法で回避してもらうようにしていますが、利用者コンソールから「キャッシュクリア」みたいな機能もつけたいなと思っています。

ちなみに「本番」モードだとブラウザのプロファイルも別になっているので、キャッシュクリアしても他のユーザには影響しないようにしています。

今後、実装したい機能

  • 一括PDF作成 (同じ形式でデータ違いのPDFファイルを作成し、zipなどに固める)
  • PDFマージ機能 (ページ数が多い場合などに分割してPDF化し、それを1つにまとめる)
  • PDFフォーム連携 ( PDFフォームをHTML化して、そこにフォームのデータを入れて再度PDF化する)
  • Cookieの引き継ぎ

最初の3つに関しての機能は実はすでに特定の顧客用には動いているのですが、最初の2つは、どのようなAPIや制限かを考え中です。前述した1000ページPDF作成の対応の延長なのですが、これをやると、結構CPUやメモリリソースを使うので他の処理に影響がないようにする為どうしたら良いかを考えています。

そして、PDFフォームは行政関連などであるのですが、現在のブラウザだとPDFフォームは日本語の問題が解決されておらず正しく表示できません。
Acrobat Readerで表示すればよいのですが、わざわざインストールしている人も減ってきているのか、日本語データが見えないので、データが壊れています。という認識になる事も多く、PDFフォームは利用出来ないと思っている人も多いです。

なので、PDFフォームをHTMLにして、フォームがある場所(領域)にHTMLで文字列をかぶせるという事を一部やっているのですが、その機能もどこかでリリースしたいと思っています。

そして、最後にCookieの引き継ぎです。
ただし、これはセキュリティ的にやっていいのだろうか?という疑問もあり、また、セキュリティホールになり得る可能性もあるので、あくまで構想レベルというところです。

こういった機能があれば使いたいみたいなご希望、感想があればコメントください。

Discussion