技術記事

PDFlibPasのEMF変換:PolyDraw、Polyline、Bezierの規則

Delphi向けのlosLab PDFライブラリであるPDFlibPasは、[MS-EMF]の各レコード定義に従って、EMFのPoly*レコードをPDFパスへ変換します。32ビットのEMR_POLYBEZIERは点0から始まり、ポリラインは開いたままストロークのみ、EMR_POLYDRAWのPT_CLOSEFIGUREはフラグであり、すべての点数はレコードサイズに対して検証されます。これらの規則はv3.539.39、v3.539.41、v3.539.43の3リリースにわたって入りました。それ以前は、レポートのチャートがImportEMFFromFileから出てくると、トレンドラインがあるべき場所に塗りつぶされたくさびが現れたり、ベジェ曲線が間違った制御点へ曲がったり、閉じた輪郭の最後の辺が欠けたりしました。どれもエラーを出さず、しかもこの規則は任意のDelphi EMF to PDFコンバーターやGDIレコードパーサーに当てはまります

PDF変換でEMF Poly*レコードがうまくいかないのはなぜか

EMFのPoly*レコードがうまくいかないのは、意味の一部が点の外側にあるからです。図形が開いているか、現在位置から始まるか、どのペンとブラシが適用されるか、レコード内のどこから点が始まるか。拡張メタファイルはデバイスコンテキストへのGDI呼び出しの記録なので、コンバーターは座標だけでなく、そのデバイスコンテキストの状態も再生しなければなりません。PDFにはデバイスコンテキストがありません。あるのはパス、その内側の現在点、そしてストローク(S)、塗り(f)、両方(B)を選ぶ描画オペレーターです。この2つのモデルの食い違いは、すべて沈黙のまま描画の違いになります

Poly*ファミリーには2つの幅もあります。32ビットレコード(例:EMR_POLYLINE)にはそれぞれ、点をSmallIntのペアで格納する16ビットの兄弟(例:EMR_POLYLINE16)が存在します。GDIはすべての座標が収まるときは通常コンパクトな方を記録するので、コンバーターの32ビットハンドラは、日常のテスト描画が一度も到達しないまま、何年も壊れたままでいられます。一番速い監査は、同じ点を両方のレコードに通して、できあがるパスを比較することです。ここで扱うレコードはすべて[MS-EMF]の描画レコードグループ(2.3.5 Drawing Record Types)に入っています

レコード開始点閉じる?現在位置
EMR_POLYBEZIER点0いいえ未使用、未更新
EMR_POLYLINE点0いいえ(ペンのみ)未使用、未更新
EMR_POLYLINETO現在位置いいえ(ペンのみ)使用し更新される
EMR_POLYPOLYLINE各ポリラインの最初の点いいえ(ペンのみ)未使用、未更新
EMR_POLYDRAW最初のPT_MOVETO、または現在位置PT_CLOSEFIGUREが立っている箇所のみ使用し更新される

EMR_POLYBEZIERの曲線が実際に始まる場所

EMR_POLYBEZIERの曲線は点0から始まり、インデックス1以降の点だけが、制御点、制御点、終点の3つ組にグループ化されます。だから7点のレコードは2つの3次セグメントを描きます。0が始点、1から3が最初のセグメント、4から6が2番目です。PDFlibPasの16ビットハンドラはすでにこうしていました。32ビットハンドラは点0からグループ化を始めたため、始点が最初の制御点として消費され、その後のセグメントがすべて1つずれました。曲線は描けてはいましたが、間違った曲線です。v3.539.41以降は、両方の幅が点0のmでパスを開き、その後の完全な3つ組ごとにcを1つ出します

点0がmでパスを開き、点1〜3と点4〜6がそれぞれ3次セグメントcを1つ形成する7点のEMR_POLYBEZIERレコードを示すPDFlibPasの図。始点を制御点として消費していた旧グループ化と、v3.539.41以降で修正された32ビットハンドラを対比します
点0が始点で、その後の完全な3つ組だけが3次セグメントになります。だから7点のPolyBezierはmと2つのcオペレーターとして描かれます

自前のパーサーへのアドバイスとしては、1+3の倍数でない点数は不正形式であり、末尾の余った点は曲線に縫い付けず無視すべきです

PolyDraw:PT_CLOSEFIGUREは点タイプではなくフラグ

EMR_POLYDRAWでは、PT_CLOSEFIGURE(値1)はPT_LINETO(2)やPT_BEZIERTO(4)と組み合わさるビットなので、型バイトとして有効な値は3や5にもなります。点の型はそのビットをマスクしたバイトで、フラグはこの点で終わるセグメントの後に図形を閉じることを意味します。旧PDFlibPasハンドラはcase文でバイトを単一の値と突き合わせていたため、型3と5の点はどこにもマッチせず、丸ごとスキップされていました。PolyDrawで描いた長方形は閉じ側の辺を失い、最後の点がフラグを運ぶベジェの3つ組はその点を失い、その後の3つ組はすべてずれ込んだままになりました

v3.539.39以降、型はTypes[i] and not PT_CLOSEFIGUREとして読まれ、閉じは完全なセグメントの後でのみ出力されます。閉じ付きPT_LINETOなら線の後、ベジェグループなら3点目の後です。3つ組の1点目や2点目にフラグを立てる不正ファイルがあっても、図形が早閉じされることはありません。同じリリースには関連する修正も2つ入っています:

  • 16ビットのEMR_POLYDRAW16ではPT_MOVETOが来るたびにパス全体が再開され、3つの図形を含むレコードは最後の1つしか残りませんでした。今は最初のムーブがパスを開始し、その後のムーブはサブパスを開きます
  • PT_MOVETOで始まらないPolyDrawレコードは、レコード定義どおり現在位置から始まり、手前にmのないlやcオペレーターを書かなくなりました
EMR_POLYDRAW型バイトの構造を示すPDFlibPasの図。PT_CLOSEFIGUREはフラグビット0で、PT_LINETOまたはPT_BEZIERTOにORされます。有効な型バイト3と5は、振り分け前にand not PT_CLOSEFIGUREでマスクしなければなりません。旧case文は両方のバイトをスキップし、閉じた図形は最後の辺を失っていました
振り分け前に閉じフラグをマスクし、閉じは完了した線かベジェ3つ組の後でのみ出力します。さもないとPolyDrawは黙って点を落とします

EMFポリラインをPDFで塗ってはいけない理由

EMFポリラインを決して塗ってはいけないのは、EMR_POLYLINEとEMR_POLYPOLYLINEがペンのみで描かれる開いた図形であり、PDFで開いたパスを塗ると暗黙的に閉じられるからです。ISO 32000-1 §8.5.3は、塗りオペレーターは描画の前に任意の開いたサブパスを閉じると定めています。だから3点のポリラインにBやfを出すコンバーターは、現在のブラシ色で塗りつぶされた三角形を描いてしまいます。チャートのトレンドラインの下の塗りつぶしくさびの正体です。v3.539.41より前のPDFlibPasは、両方の幅のポリラインをブラシで塗り、32ビットレコードはさらに明示的に閉じていました。今は両方ともストロークだけで終わり、GDIの区別は保たれます。Polygonは閉じて塗り、Polylineは決してそうしません

EMR_POLYLINEから書き出された開いたV字ポリラインの比較を示すPDFlibPasの図。正しいコンバーターはパスをストロークオペレーターSで終え、選択されたブラシを無視します。fやBを出すとISO 32000-1 8.5.3のもとで開いたサブパスが暗黙に閉じられ、塗りつぶしくさびのチャートバグが描かれます
塗りオペレーターは描画前にあらゆる開いたサブパスを閉じます。だからポリラインはSで終えるべきで、サブパスにh、f、Bを置いてはいけません

PolylineToは現在位置から始まる

EMR_POLYLINETOは現在位置からレコード内のすべての点を通って描き、開いたままになり、現在位置を最後の点に置きます。旧ハンドラには、最初の2点がy座標を共有するときにペンをオフにする特殊ケースまで入っていて、それを元に戻すコードはどこにもなかったため、ファイル内のその後のレコードはすべて輪郭を失いました。ペンの状態はEMR_SELECTOBJECTとEMR_CREATEPENの管轄であって、描画レコードのハンドラが触れるものではありません。この特殊ケースはv3.539.41で取り除かれ、レコードの1点形式は自分の点の先を読まなくなりました(v3.539.39で修正)

PolyPolylineの点はカウント配列の後から始まる

32ビットのEMR_POLYPOLYLINEはnPolys個のカウントを格納し、その後にcptl個の点が続きます。点はバイトオフセット32 + nPolys * 4から始まります。罠はRTL側にあります。WindowsユニットはTEMRPolyPolylineを、aPolyCountsとaptlが要素1つの配列であると宣言しているので、aptl[0]が最初の点になるのはnPolysが1のときだけです。aptlを直接インデックスするコードは、複数ラインのレコードすべてでカウント値を座標として読みます。旧PDFlibPasハンドラは境界チェックのサイズもその間違ったレイアウトで計算していたため、有効な複数ラインレコードは拒否され、単一ラインのものは何も描きませんでした。v3.539.41以降、PDFlibPasはPolyPolygonハンドラが昔からそうしていたように、計算したオフセットから点配列を位置特定し、各ポリラインを独自の開いたサブパスとして描き、最後に1回ストロークします。v3.539.43では16ビットの兄弟も同じ扱いになりました。こちらはセグメントごとに描いていたため、線の結合が壊れ、選択済みのNULL_PENが無視されていました

デフォルトのペンとブラシ、そしてパスブラケット

v3.539.43のポリライン修正を締めくくる状態ルールが2つあります:

  • 新しいGDIデバイスコンテキストには最初からBLACK_PENとWHITE_BRUSHが選択されているので、EMR_SELECTOBJECTなしで描くメタファイルでも黒い輪郭が描かれます。コンバーターは以前、ペンも塗りもない状態で始め、その種のレコードにn(パスを終え、何も描かない)を書いていました
  • BeginPath / EndPathブラケットの内側では、Polylineは現在位置を使わず更新もしません。だから前の図形に接続するのでなく、最初の点で新しいサブパスを開かなければならず、ブラケットがストロークか塗りで使われるまで何も描いてはいけません

TMetafileCanvasでEMFテストファイルを作る

コンバーターをこれらの規則に対して検査する一番速い方法は、3つの危険な呼び出しをTMetafileCanvasで1つの拡張メタファイルに記録することです。下の描画コードは、曲線を中空ブラシで記録してから、ポリラインのためにわざとソリッドの黄色ブラシを選びます。正しいコンバーターならポリラインに対してそのブラシを無視するはずで、出力PDFに黄色があればバグです。PolyDrawにはTCanvasラッパーがないので、キャンバスハンドルを使ってWindows API経由で呼び出し、型バイト3と5で閉じフラグを試します

uses
  Winapi.Windows, System.Types, Vcl.Graphics;

procedure BuildPolyTestEmf(const FileName: string);
const
  // 閉じた正方形(3 = LINETO + CLOSEFIGURE)、続いて閉じたBezier図形
  // 最後の制御3つ組が 5 = BEZIERTO + CLOSEFIGURE で終わるもの
  DrawPts: array[0..7] of TPoint = (
    (X: 300; Y: 40), (X: 380; Y: 40), (X: 380; Y: 120), (X: 300; Y: 120),
    (X: 420; Y: 120), (X: 440; Y: 40), (X: 520; Y: 40), (X: 540; Y: 120));
  DrawTypes: array[0..7] of Byte = (
    PT_MOVETO, PT_LINETO, PT_LINETO, PT_LINETO or PT_CLOSEFIGURE,
    PT_MOVETO, PT_BEZIERTO, PT_BEZIERTO, PT_BEZIERTO or PT_CLOSEFIGURE);
var
  Mf: TMetafile;
  Canvas: TMetafileCanvas;
begin
  Mf := TMetafile.Create;
  try
    Mf.Enhanced := True;
    Mf.Width := 600;
    Mf.Height := 260;
    Canvas := TMetafileCanvas.Create(Mf, 0);
    try
      Canvas.Pen.Color := clNavy;
      Canvas.Pen.Width := 2;
      Canvas.Brush.Style := bsClear;    // 曲線は輪郭のみ
      // 点0が始点。1..3と4..6が2つの3次セグメント
      Canvas.PolyBezier([Point(20, 120), Point(60, 20), Point(100, 220),
        Point(140, 120), Point(180, 20), Point(220, 220), Point(260, 120)]);
      PolyDraw(Canvas.Handle, DrawPts[0], DrawTypes[0], Length(DrawPts));
      // ソリッドブラシ選択中の開いたV字。ストロークのみで
      // 黄色の三角形には閉じない
      Canvas.Brush.Style := bsSolid;
      Canvas.Brush.Color := clYellow;
      Canvas.Polyline([Point(20, 240), Point(120, 160), Point(220, 240)]);
    finally
      Canvas.Free;   // 記録を終える
    end;
    Mf.SaveToFile(FileName);
  finally
    Mf.Free;
  end;
end;

これらの座標はSmallIntに収まるので、GDIは通常16ビット版を記録します。32ビットハンドラに到達するには、それを書き出すプロデューサーか、手で組み立てたレコードが必要です。手組みのファイルには固有の罠もあります。VCLのTMetafile.LoadFromStreamは、残り長さが108バイトのTEnhMetaHeaderより厳密に大きいときにだけ、ストリームをEMFとして扱います。短いヘッダーの手書きEMFや、ちょうど108バイトの空のEMFはWMFとみなされ、"Metafile is not valid"で拒否されます。テストレコードの前には、拡張フィールドを含む完全な108バイトのヘッダーを必ず書いてください

PDFlibPasでEMFをPDFへインポートする

PDFlibPasはImportEMFFromFileかImportEMFFromStreamでEMFをインポートします。成功時は非ゼロの画像IDを返し、失敗時は0です。GeneralOptions = 0は、この記事の主題であるベクターパスを保持します。1は代わりにメタファイルをビットマップへラスタライズします。FontOptions = 1は、メタファイルのフォントを非埋め込みTrueTypeフォントとして追加します。ストリーム版はロード前にストリームを位置0へ巻き戻すので、メタファイルだけを含むストリームを渡してください

uses
  System.SysUtils, PDFlibrary;

procedure EmfToPdf(const EmfFile, PdfFile: WideString);
var
  PDF: TPDFlib;
  ImageID: Integer;
  PageOps: AnsiString;
begin
  PDF := TPDFlib.Create;
  try
    PDF.SetOrigin(1);              // DrawImage用の左上原点
    PDF.SetMeasurementUnits(0);    // ポイント
    // FontOptions 1 = フォントを非埋め込みTrueTypeとして追加
    // GeneralOptions 0 = ベクターインポート、1 = ビットマップ
    ImageID := PDF.ImportEMFFromFile(EmfFile, 1, 0);
    if ImageID = 0 then
      raise Exception.Create('The metafile could not be imported');
    PDF.SelectImage(ImageID);
    // EMFの場合、ImageWidth / ImageHeightはポイント単位のフレームサイズ
    PDF.DrawImage(36, 36, PDF.ImageWidth, PDF.ImageHeight);

    // ページは取り込んだフォームを呼ぶだけ:q ... cm /Name Do Q
    PageOps := PDF.GetPageContentToString;
    if Pos(AnsiString(' Do'), PageOps) = 0 then
      raise Exception.Create('Expected a form XObject invocation');

    if PDF.SaveToFile(PdfFile) <> 1 then
      raise Exception.Create('The PDF could not be saved');
  finally
    PDF.Free;
  end;
end;

ベクターのEMFインポートはフォームXObjectになるので、GetPageContentToStringが返すのはセーブ、変換、Do、リストアのシーケンスだけです。Poly*レコードから生成されたm、l、c、h、Sの各オペレーターは、圧縮されたフォームXObjectストリームの中に住んでいます。監査するには、保存したファイルをPDFオブジェクトインスペクターで展開し、フォームストリームを読みます。上のテストファイルなら、ポリラインが手前にhなしでSで終わること、PolyDraw図形の閉じフラグごとにhがあること、これらのサブパスにfやBが一切ないことを確認するはずです。DrawImageは取り込んだEMFをWidthとHeightの小さい方で一様にスケールするので、渡すボックスが縦横比と合っていなくても、描画はアスペクト比を保ちます

Free Pascalターゲットについては、PDFlibPasのEMFベクターインポーターがFree Pascalでどうビルドされるかを参照してください。インポーターがどこでコンパイルされようと、レコードのセマンティクスは同じです

EMFパーサーはファイル由来の点数をどう扱うべきか

EMFパーサーは、すべての点数を信頼できない入力として扱い、1点でもコピーする前にレコードサイズに対して検証すべきです。EnumEnhMetaFileが保証するのは、各レコードのnSizeがファイル内に収まることだけです。cptlがnSizeと整合するかは見ていません。だからカウントが偽造・破損していたとき、Moveでcptl点をコピーするハンドラは、後続のレコードやメタファイルの末尾の先まで読みに行きます。v3.539.39以降、PDFlibPasはPolyDraw、PolyBezier、PolyBezierTo、Polyline、PolylineTo、Polygonの両方の幅について、固定ヘッダー+点数×点あたりバイト数をnSizeに対して検査し、PolyDrawの型バイトは1点あたり1バイト余分に数えます。PolyPolyレコードでは、図形ごとのカウントの合計が宣言された総数を超えないことも要件で、0点の図形はスキップされます

同じ検査は、自前のパーサーにコピーできるほど短いものです。このバージョンは32ビットのEMR_POLYPOLYLINEを検証し、実際の点配列へのポインターを返します:

uses
  Winapi.Windows;

// レコードが宣言どおりの点を本当に保持する場合を除きnilを返す
// 点はカウント配列の後から始まる:32 + nPolys * 4 バイト先であり
// RTLが要素1つの配列と宣言するaptl[0]ではない
function PolyPolylinePoints(Rec: PEnhMetaRecord): PPoint;
var
  P: PEMRPolyPolyline;
  Count: PDWORD;
  PointsOffset, Total: Int64;
  I: Cardinal;
begin
  Result := nil;
  if (Rec^.iType <> EMR_POLYPOLYLINE) or (Rec^.nSize < 32) then
    Exit;
  P := PEMRPolyPolyline(Rec);
  if P^.nPolys = 0 then
    Exit;
  PointsOffset := 32 + Int64(P^.nPolys) * SizeOf(DWORD);
  if PointsOffset + Int64(P^.cptl) * SizeOf(TPoint) > Rec^.nSize then
    Exit;                         // 偽造または切り詰められたカウント
  Total := 0;
  Count := @P^.aPolyCounts[0];    // ポインターで歩く:[0..0]は範囲チェックに引っかかる
  for I := 1 to P^.nPolys do
  begin
    Inc(Total, Count^);
    Inc(Count);
  end;
  if Total > P^.cptl then
    Exit;                         // 図形が実在より多くの点を要求している
  Result := PPoint(NativeUInt(Rec) + NativeUInt(PointsOffset));
end;

点が収まるかの検査が先に走るので、ループが歩く時点でカウント配列がレコード内にあることは確かめられています。算術がInt64なのは、32ビットで計算したnPolys * 4やcptl * 8はラップアラウンドして比較をすり抜け得るからです

クイックリファレンス:EMF to PDF変換のためのEMF Poly*規則

  • EMR_POLYBEZIER:点0が始点。点1から3つ組でグループ化。32ビットレコードはv3.539.41で修正
  • EMR_POLYLINE / EMR_POLYPOLYLINE:開いた図形。Sでストロークし、h、f、Bは決して使わない。PDFの塗りは開いたサブパスを閉じるため
  • EMR_POLYLINETO:現在位置から始め、開いたままにし、現在位置を更新し、ペンの状態には決して触れない
  • 32ビットのEMR_POLYPOLYLINE:点はバイト32 + nPolys * 4から始まり、aptl[0]ではない
  • EMR_POLYDRAW:振り分け前にPT_CLOSEFIGUREをマスク、完了したセグメントの後で閉じる、最初の点がPT_MOVETOでなければ現在位置から始める
  • デフォルトのデバイスコンテキスト状態はBLACK_PEN+WHITE_BRUSH。v3.539.43以降はこれを尊重する
  • BeginPath / EndPathの内側では、各ポリラインが自分のサブパスを開き、ブラケットが使われるまで何も描かれない
  • 点をコピーする前に、すべてのcptl / cptsを64ビット算術でnSizeに対して検証する
  • 手組みのテストEMFには完全な108バイトのヘッダーが要る。さもないとTMetafile.LoadFromStreamはWMFとして読む

レポートが別のコンポーネントを通る場合も、レコードのセマンティクスは同じです。HotPDFのEMFとWMFベクターインポートは、あちらのコンポーネントがグラデーションやハッチブラシをどうPDFパターンに変えるかを扱い、PDFlibPasのベクターグラフィックス、シェーダー、グラデーションは、メタファイルを経由せずライブラリAPIで直接同じ図形を描く方法を扱います

上記の規則はすべて、PDFlibPas v3.539.43以降に入っています。詳細とトライアルダウンロードは、PDFlibPas Delphi PDF library製品ページにあります