技術記事

PDFlibPasのHTML to PDF:二重デコードされる実体の修正

PDF Library for Delphi(PDFlibPas)のv3.539.47より前のバージョンは、HTMLやMarkdownをPDFへ描くときに、エスケープ済みテキストを二重にデコードすることがありました。DrawHTMLTextとDrawHTMLTextBoxはHTMLをパースし、正規化して再びHTMLに戻し、それをもう一度パースします。だから<unsafe>と書かれたテキストは、2回目のパースには本物のタグとして届きました。v3.539.47以降は各実体がちょうど1回だけデコードされ、テキストが再びHTMLになる場所では必ず再エスケープされます

これが露呈するシナリオはごく普通のものです。ヘルプデスクがチケットをPDFへ書き出し、顧客コメントがHTMLテンプレートに入ります。開発者は正しいことをしてコメントをエスケープしたので、<b>は&lt;b&gt;になっていました。ところがレンダラーの内側でそのエスケープが静かに裏切られたのです。コメントは太字で出力され、知らないタグ名はページから消え、エスケープされたアンカーはクリック可能なリンクアノテーションに化けました。例外も警告もなし、データと違う内容を語る、完全に有効なPDFができあがります

エスケープ済みテキストがPDFの中で本物のタグになる理由

エスケープ済みテキストがマークアップになったのは、レンダラーがパースを2回走らせ、その間の正規化ステップが、すでにデコード済みのテキストを再エスケープせずにHTMLへ書き戻していたからです。最初のパースが行ったデコードはすべて、2回目のパースには生きた構文として見えていました

この2パスには正当な理由があります。最初のパースがタグ要素と語要素のリストを組み立て、続くNormalizeParsedHTMLがスタイルシートのカスケードを解決します。<style>ブロックのルールを各タグに照合し、インラインのstyle属性とマージし、結果をタグに保存し、要素リスト全体をHTML文字列へシリアライズし直すのです。レイアウトパスがその正規化済み文字列をパースします。これはPDFlibPas HTMLレンダリングのflexbox、CSSグリッド、脚注レイアウトを動かしているのと同じ機構です

欠陥は語のシリアライズ方法にありました。タグは元のソース形のまま書き戻されていたのに対し、語はデコード後の形で書き戻されていました。最初のパースが&lt;unsafe&gt;から<unsafe>へデコードした語は、生の山括弧のまま正規化済みHTMLに着地し、2回目のパースはそれを要素として読みました。この核心バグの周りには、同じ方向を指す小さな漏れが3つありました:

  • &amp;はサポート対象の実体セットに入っていなかったため、R&amp;Dはそのまま印字され、&lt;のような実体表記をテキストとして書く手段がありませんでした
  • 描画ステージがパース終了後に&nbsp;をもう一度置換していたため、実体表記が最後の最後で消えることがありました
  • Markdownのコードエスケープはアンパサンドをスキップし、データセットエクスポーターは山括弧だけをエスケープしていたため、コードやセル値の中の実体表記がマークアップとしてデコードされていました
DrawHTMLTextのPDFlibPas HTMLパイプラインを示す図。パース1が要素を組み立て、NormalizeParsedHTMLがHTMLへ書き戻し、パース2がその結果をレイアウトします。v3.539.47より前はデコード済みの語がエスケープされずに書き戻されて生きたタグになり、v3.539.47以降はすべての語が境界で再エスケープされます
正規化器が自分の出力がマークアップであることを忘れると、デコード済みの語は構文としてパーサーに再入場します。エスケープ済みコメントが太字になったりリンクを生やしたりしたのはこのためです
レンダラーに届く入力v3.539.47より前v3.539.47以降
&lt;unsafe&gt;タグとしてパースされ、テキストはページに届かない<unsafe>がテキストとして描かれる
&lt;b&gt;x&lt;/b&gt;xが太字で描かれる<b>x</b>がテキストとして描かれる
R&amp;DR&amp;Dがそのまま印字されるR&D
&amp;lt;&amp;lt;がそのまま印字される&lt;
Markdownのコードスパンに&nbsp;を含むノーブレークスペースになった&nbsp;がテキストとして描かれる
データセットのセル値&lt;<&lt;

v3.539.47がHTML実体デコードをシングルパスにする仕組み

PDFlibPas v3.539.47は、3つの連動した変更で実体デコードをシングルパスにします。パーサーは&amp;を最後にデコードする、描画ステージは何もデコードしない、デコード済みの語をHTMLへ戻すすべての場所が先に再エスケープする、の3つです

テキストコンテンツのサポート実体セットは、現在は&lt;、&gt;、&amp;、&nbsp;の4つです。それ以外、たとえば&#65;のような数値参照や&quot;のような名前付き実体は、リテラルテキストのままになります。この境界は、後述するとおり、自分の入力をどうエスケープするかに効いてきます

1つ目の修正はデコーダー内の順序です。もし&amp;が最初にデコードされると、入力&amp;lt;は&lt;になり、次の置換でそれが<へ変わります。1パスの中で起きる二重デコードです。そこでANSI語パスは&lt;、&gt;、&nbsp;を先に置換し、&amp;を最後に置換します。こうすれば、自分が作り出したアンパサンドが再び検査されることはありません。UTF-16語パスは、2バイトステップで左から右へ一度だけ走査し、各マッチをその場で書き換えて先へ進む方式で、構造的に同じ保証を持ちます

&amp;lt;のような連鎖実体に対するPDFlibPasデコーダーの順序を示す図。アンパサンドを先にデコードすると1パスの中で本物の山括弧に潰れますが、アンパサンドの前にlt、gt、nbspをデコードすればリテラル表記が保たれ、テキストはちょうど1回のデコードでページに届きます
アンパサンドはエスケープ文字なので、最後にデコードし最初にエスケープしなければなりません。さもないと1パスで二重デコードが起きます

2つ目の修正は、描画ステージから遅延の&nbsp;置換を取り除いたことです。デコードはパーサーの専権事項で、それ以外にありません。行分割器に届く語は最終テキストです

3つ目の修正が境界ルールです。NormalizeParsedHTMLは、デコード済みの語を正規化済みHTMLに追加する前に、その語の中の&、<、>をエスケープするようになりました。2回目のパースがそれをまったく同じテキストへデコードし直すので、パイプライン全体を通した純効果は1回のデコードです。継続文字列も同じルールに従います。ボックスに収まらなかった語はLeftOverTextへ追加される前にエスケープされ、残りの部分は正規化済みHTMLからコピーされます。こちらはすでにエスケープ済みの形です。残り語を集めるループも語数で上限が付きました。旧のrepeatループは最後の語を踏み越え得たのです

UTF-16BEのエスケープにバイト単位の置換が使えない理由

UTF-16BEのエスケープにバイト単位の置換が使えないのは、アンパサンドの2バイトパターンが、無関係な2文字にまたがり得るからです。正しく唯一の作業単位は、16ビットコードユニット全体です

レンダラーはUnicodeの語を、ビッグエンディアンUTF-16としてバイト文字列に詰めて保存します。上位バイトが先です。アンパサンドは00 26です。ここでU+0100(マクロン付きラテン大文字A、バイトは01 00)の後にU+2603(雪だるま、バイトは26 03)が続くケースを見てください。バイト列は01 00 26 03となり、2番目と3番目のバイトは00 26と読めます。#0'&'でのバイト検索は存在しないアンパサンドを発見し、&amp;のバイトを2文字の途中へ接ぎ込み、以降の文字をすべて1バイトずらします

U+0100とU+2603のバイト01 00 26 03が2文字にまたがってパターン00 26を含む、PDFlibPasのUTF-16BEエスケープの危険を示す図。アンパサンドのバイト単位検索はコードポイントの途中へ実体を接ぎ込みます。コードユニット走査は偶数オフセットだけを検査します
バイト検索は、どの文字も含んでいなかったアンパサンドを見つけ出します。生のUTF-16バイトバッファではなく、コードユニット全体で作業してください

これは特殊なコーナーケースではありません。下位バイトがゼロの文字なら何でも前半を供給できます。最も頻出するCJK漢字の1つであるU+4E00も該当します。山括弧も同じリスクを抱えます。そういう文字の後にCJK Extension AのU+3C00からU+3EFFの文字が続けば、00 3Cや00 3Eはいくらでも現れます。EscapeHTMLWordでの修正は、バイトをWideStringへ展開し、1文字ずつエスケープして、結果を再び詰め直すものです。デコーダー側は、偶数コードユニット境界でのみパターンを検査していたため、すでに安全でした

同じルールはあなたのコードにも当てはまります。UTF-16テキストをTBytesとして持つ場面、たとえばTEncoding.BigEndianUnicode.GetBytesの後では、バイトパターンで検索しないでください。文字列へ戻して、文字で作業します

Markdownコードブロックとデータセット書き出し:アンパサンドを先にエスケープ

v3.539.47以降、PDFlibPas内の2つのHTML生成物、つまりMarkdownコンバーターとデータセットエクスポーターは、山括弧の前にアンパサンドをエスケープします。レンダラー内の1回のデコードが、元のテキストをそっくり復元するためです

MarkdownToHTMLでは、インラインコードスパンとフェンス付き・インデント式のコードブロックが、現在は&を&amp;へ、<を&lt;へ、>を&gt;へマップし、スペースは&nbsp;になり、タブはインデントを保つためにその4つになります。普通のMarkdownの散文は山括弧だけをエスケープします。散文内の生HTMLはタグを注入できず、それでいて作者は意図して&amp;と書ける、Markdown作者が期待するとおりの挙動です。DrawMarkdownTextとDrawMarkdownTextBoxは同じ変換を使うので、コードは打ったとおりにPDFへ現れます:

uses
  System.SysUtils, PDFlibrary;

procedure RenderCodeSample;
var
  Lib: TPDFlib;
  Md, Html: WideString;
begin
  Md := 'Comparison helper:' + sLineBreak + sLineBreak +
        '```' + sLineBreak +
        'if (A < B) and (Flags <> 0) then' + sLineBreak +
        '  WriteLn(''&lt;tag&gt; &amp; R&amp;D'');' + sLineBreak +
        '```';
  Lib := TPDFlib.Create;
  try
    // HTMLを検査:コード内では'&'が'&amp;'に、'<'が'&lt;'になる
    Html := Lib.MarkdownToHTML(Md);
    Lib.SetOrigin(1);            // 左上原点、Yは下向きに伸びる
    Lib.SetMeasurementUnits(0);  // ポイント
    // ページにはコードが打ったとおりに表示される。実体の表記も含めて
    Lib.DrawMarkdownText(50, 50, 495, Md);
    Lib.SaveToFile('code-sample.pdf');
  finally
    Lib.Free;
  end;
end;

データセットエクスポーターはためになる事例です。v3.539.47より前は山括弧だけを、それも意図的にエスケープしていました。レンダラーが&amp;をデコードしなかったので、アンパサンドをエスケープすると、それを含むすべてのセルに&amp;と印字されていたはずだからです。この回避策は旧レンダラーには正しく、一般には間違っていました。たまたま&lt;を含むセル値が<へデコードされていたからです。レンダラーが直った今、エクスポーターは&を先にエスケープし、R&D &lt; &amp; &nbsp;のような値はそのままPDFへ着地します。この方式でレポートを組んでいるなら、DelphiでTDataSetをPDFレポートへ書き出す方法の解説がエクスポーターの残りを扱います

なぜアンパサンドを先にしなければならないかは、一度だけ明確にしておく価値があります。<を先にエスケープすると&lt;になり、次に&をエスケープするとそれは&amp;lt;になります。正しいシングルデコードはこれを<ではなく&lt;と表示します。順次置換チェーンが正しいのは、エスケープ文字そのものが、それを持ち込む何よりも先に処理されるときだけです

DrawHTMLTextBox向けに信頼できないテキストはどうエスケープすべきか

PDFlibPasのHTMLレンダリングでは、信頼できないテキストコンテンツは&、次に<、次に>の順で、ちょうど1回置換してエスケープします。そして信頼できないデータは属性値へは一切入れません

uses
  System.SysUtils, PDFlibrary;

// PDFlibPasのHTMLテキストコンテンツ向けに信頼できないテキストをエスケープする
// '&'を最初に置換すること。そうしないと、すでに作られた
// '&lt;'の中のアンパサンドが2度目のエスケープを受ける
function EscapeHTMLText(const S: string): string;
begin
  Result := StringReplace(S, '&', '&amp;', [rfReplaceAll]);
  Result := StringReplace(Result, '<', '&lt;', [rfReplaceAll]);
  Result := StringReplace(Result, '>', '&gt;', [rfReplaceAll]);
end;

procedure RenderTicket(const CustomerComment: string);
var
  Lib: TPDFlib;
  Html: WideString;
begin
  Lib := TPDFlib.Create;
  try
    Lib.SetOrigin(1);
    Lib.SetMeasurementUnits(0);
    Html := '<p><b>Customer comment</b></p>' +
            '<p>' + EscapeHTMLText(CustomerComment) + '</p>';
    Lib.DrawHTMLText(50, 50, 495, Html);
    Lib.SaveToFile('ticket.pdf');
  finally
    Lib.Free;
  end;
end;

v3.539.47では、Try <a href="https://example.com">this</a> & &lt;b&gt;のようなコメントが、1文字ずつそのままページへ現れます。v3.539.47より前は、同じエスケープ済み入力が生きたリンクアノテーションを生み得ました。表示の不具合がセキュリティ問題に化けるのはここです。チケットのコメントが、職員が信頼する文書の中にクリック可能なURLを植え付けていいはずがありません

この関数がエスケープしないものにも注目してください。汎用のHTMLエスケーパーはさらに"を&quot;へ、'を&#39;へ変換します。ブラウザならそれで正しい。ところがPDFlibPasのテキストデコードは先に挙げた4つの実体しか認識しないので、この2つは&quot;と&#39;のまま印字されます。引用符はテキストコンテンツでは無害です。効いてくるのは属性値の中だけで、レンダラーは属性内の実体をそもそもデコードしません。だから安全な設計は、より賢いエスケーパーではなくルールです。信頼できないデータはhref、src、styleへ決して入れない。リンク先が本当にユーザーデータから来なければならないなら、スキームと文字の許可リストで自分で検証し、引用符や山括弧を含むものは拒否してください

この修正から、アップグレード時の注意が2つ直接導かれます:

  • 旧バージョンが&amp;をそのまま印字していたせいで、コードから&のエスケープを外していたなら、戻してください。ないままだと、&lt;を含むユーザーテキストが<と表示されます。無害なテキストではあるものの、ユーザーが打ったものではなくなります
  • 二重エスケープはしないこと。2つのエスケーパーを通ったテキストは、<を可視の表記&lt;として描画します。データがHTMLへ入る境界を1か所特定し、そこだけでエスケープしてください

LeftOverTextでのページ分割、エスケープを壊さずに

DrawHTMLTextBoxは収まらなかったHTMLを返します。俗にLeftOverTextと呼ばれるものです。v3.539.47以降、この残りはリテラルの実体表記とエスケープ済み山括弧を保ったまま、次のボックスへ渡せます。呼び出し側のルールは単純です。変更せずにそのまま返す

const
  BoxLeft = 50;
  BoxTop = 50;
  BoxWidth = 495;    // A4ページをポイントでサイズ化した値
  BoxHeight = 740;
  MaxPages = 500;

procedure RenderLongHTML(Lib: TPDFlib; const Html: WideString);
var
  Rest: WideString;
  Pages: Integer;
begin
  Lib.SetOrigin(1);
  Lib.SetMeasurementUnits(0);
  Rest := Lib.DrawHTMLTextBox(BoxLeft, BoxTop, BoxWidth, BoxHeight, Html);
  Pages := 1;
  while (Rest <> '') and (Pages < MaxPages) do
  begin
    Lib.NewPage;
    Inc(Pages);
    // LeftOverTextはすでにエスケープ済みのエンジンHTML。エスケープも解除もしない
    Rest := Lib.DrawHTMLTextBox(BoxLeft, BoxTop, BoxWidth, BoxHeight, Rest);
  end;
  if Rest <> '' then
    raise Exception.CreateFmt('Content still left after %d pages', [MaxPages]);
end;

残りは不透明なものとして扱ってください。エンジンの正規化済みHTMLで、スタイルはすでに解決済みです。自分のエスケーパーに通さず、デコードもせず、ユーザーテキストを接ぎ込むこともしない。ページ上限は安い保険です。どの要素もボックスに絶対収まらない場合、上限なしのループには自然な出口がありません

Markdownには独自の継続があります。DrawMarkdownTextBoxは内部マーカーで始まるトークンを返し、次の呼び出しが変換をスキップできるようにします。このトークンの行き先はDrawMarkdownTextBoxかDrawMarkdownTextで、HTML系の入口ではありません。そこへ渡すとマーカーがテキストとして描かれます

一般的な教訓:デコードは1回、境界ごとに再エンコード

テキストをパースし、結果を同じ構文へシリアライズし、またパースするパイプラインはどれも、デコードをちょうど1か所で起きる操作として扱い、デコード済みテキストが再び構文になる境界ごとに再エンコードしなければなりません。テンプレートエンジン、HTMLサニタイザー、MarkdownからHTMLからPDFへのチェーンは、みな同じ形を持ち、シリアライザーが自分の出力がマークアップであることを忘れた瞬間、同じように壊れます

形さえ知ってしまえば症状は予測できます。再エンコードが足りないとデータが構文になります。注入方向です。エンコードが過剰だったり、デコーダーが2回走ったりすると、実体表記が読者に見えたり食べられたりします。表示方向です。片方向だけ直すと通常もう片方が壊れる。だからPDFlibPasの修正は、&amp;デコードの追加、順序の入れ替え、遅延デコードの削除、再エスケープの追加を、同じリリースでやらざるを得なかったのです。同じ原則は逆方向にも走ります。PDFコンテンツが構造化テキストとして書き出されるとき、つまりDelphiからのPDF to Markdown/DOCXセマンティックエクスポートのように、リテラル文字1つ1つがターゲット構文向けにちょうど1回エスケープされなければならない場面です

クイックリファレンスチェックリスト

  • ユーザーデータを含むHTMLやMarkdownを描画しているなら、PDFlibPas v3.539.47以降へアップグレードする
  • テキストコンテンツは&を先に、次に<と>でエスケープする。PDFlibPasのテキストでは引用符を変換しない
  • エスケープは1回だけ。データがHTML文字列へ入る唯一の地点で行う
  • 信頼できない値はhref、src、styleに入れない。入れるなら許可リストで検証する
  • テキストでデコードされると期待していいのは&lt;、&gt;、&amp;、&nbsp;だけ。他の実体はリテラルのまま
  • LeftOverTextは変更せずDrawHTMLTextBoxへ返し、ページループには上限を付ける
  • Markdownの継続トークンはDrawMarkdownTextBoxかDrawMarkdownTextへだけ渡す
  • UTF-16バイトバッファをバイトパターンで検索しない。コードユニット全体で作業する

HTMLとMarkdownのレンダリング、データセットレポート書き出し、レイアウトエンジンの残りは、DelphiとFree Pascal向けのPDF Library for DelphiのネイティブPascalソースに収められています。エディション、プラットフォーム対応、トライアルダウンロードはPDFlibPas製品ページを参照してください