기술 문서

PDFlibPas EMF 임포트: PolyDraw·Polyline·Bezier

Delphi용 losLab PDF 라이브러리인 PDFlibPas는 [MS-EMF]의 각 레코드 정의를 그대로 따라 EMF Poly* 레코드를 PDF 경로로 변환합니다. 32비트 EMR_POLYBEZIER는 포인트 0에서 시작하고, polyline은 열린 채 스트로크만 적용되며, EMR_POLYDRAW의 PT_CLOSEFIGURE는 플래그이고, 모든 포인트 개수는 레코드 크기와 대조해 검사합니다. 이 규칙들은 v3.539.39, v3.539.41, v3.539.43에 걸쳐 들어왔습니다. 그 전에는 ImportEMFFromFile이 내놓은 리포트 차트에서 추세선 자리에 채워진 부채꼴이 나오거나, Bezier 곡선이 엉뚱한 제어점으로 휘거나, 닫힌 윤곽에서 마지막 변이 빠지곤 했습니다. 이 중 하나도 오류를 일으키지 않았고, 이 규칙은 모든 Delphi EMF to PDF 변환기와 GDI 레코드 파서에 적용됩니다

EMF Poly* 레코드는 PDF 변환에서 왜 잘못될까?

EMF Poly* 레코드가 잘못되는 이유는 각 레코드가 뜻의 일부를 포인트 밖에 실고 있기 때문입니다. 도형이 열려 있는지, 현재 위치에서 시작하는지, 어떤 pen과 brush가 적용되는지, 포인트가 레코드 안 어디서 시작하는지까지요. enhanced metafile은 디바이스 컨텍스트에 대한 GDI 호출의 녹화이므로, 변환기는 좌표만이 아니라 그 디바이스 컨텍스트 상태까지 재현해야 합니다. PDF에는 디바이스 컨텍스트가 없습니다. path와 그 안의 현재 위치, 그리고 stroke(S)와 fill(f), 둘 다(B)를 가리는 페인팅 연산자가 있을 뿐입니다. 두 모델 사이의 모든 불일치는 소리 없는 렌더링 차이로 번집니다

Poly* 계열은 두 가지 폭으로 옵니다. EMR_POLYLINE 같은 32비트 레코드마다 포인트를 SmallInt 쌍으로 저장하는 EMR_POLYLINE16 같은 16비트 쌍둥이가 있죠. GDI는 좌표가 다 들어갈 때 보통 압축 형태를 기록하므로, 변환기의 32비트 핸들러는 평소 테스트 드로잉이 닿지 않는 덕에 몇 년씩 틀린 채로 남아 있을 수 있습니다. 가장 빠른 점검법은 같은 포인트를 양쪽 레코드로 흘려 보내고 나온 경로를 비교하는 것입니다. 여기서 다루는 레코드는 모두 [MS-EMF]의 드로잉 레코드 그룹(2.3.5 Drawing Record Types)에 속합니다

레코드시작 위치닫힘?현재 위치
EMR_POLYBEZIER포인트 0아니요사용 안 함, 갱신 안 함
EMR_POLYLINE포인트 0아니요 (pen만)사용 안 함, 갱신 안 함
EMR_POLYLINETO현재 위치아니요 (pen만)사용 및 갱신
EMR_POLYPOLYLINE각 polyline의 첫 포인트아니요 (pen만)사용 안 함, 갱신 안 함
EMR_POLYDRAW첫 PT_MOVETO, 또는 현재 위치PT_CLOSEFIGURE가 설정된 곳만사용 및 갱신

EMR_POLYBEZIER 곡선은 실제로 어디서 시작할까?

EMR_POLYBEZIER 곡선은 포인트 0에서 시작하며, 인덱스 1부터의 포인트만 제어점, 제어점, 끝점의 삼 개조로 묶입니다. 따라서 포인트 7개짜리 레코드는 3차 세그먼트 두 개를 그립니다. 0이 시작점, 1부터 3이 첫 세그먼트, 4부터 6이 둘째 세그먼트죠. PDFlibPas의 16비트 핸들러는 이미 이렇게 했습니다. 32비트 핸들러는 포인트 0부터 묶기 시작했으니 시작점이 첫 제어점으로 소모되고 이후 모든 세그먼트가 한 칸씩 밀렸습니다. 곡선은 그려졌지만 엉뚱한 곡선이었죠. v3.539.41부터는 두 폭 모두 포인트 0에서 m으로 경로를 열고 그 뒤의 완성된 삼 개조마다 c를 하나씩 내보냅니다

PDFlibPas 다이어그램: 포인트 일곱 개짜리 EMR_POLYBEZIER 레코드에서 포인트 0이 m으로 경로를 열고 포인트 1부터 3, 4부터 6이 각각 3차 c 세그먼트 하나를 이루며, v3.539.41부터 고쳐진 32비트 핸들러와 시작점을 제어점으로 소모하던 옛 묶음을 대비합니다
포인트 0이 시작점이고 그 뒤의 완성된 삼 개조만 3차 세그먼트가 되므로, 일곱 포인트짜리 PolyBezier는 m과 c 연산자 두 개로 렌더링됩니다

직접 파서를 만든다면: 1 더하기 3의 배수가 아닌 개수는 malformed이고, 뒤에 남는 포인트는 곡선에 꿰매지 말고 무시해야 합니다

PolyDraw: PT_CLOSEFIGURE는 플래그지 포인트 타입이 아니다

EMR_POLYDRAW에서 PT_CLOSEFIGURE(값 1)는 PT_LINETO(2)나 PT_BEZIERTO(4)와 결합되는 비트이므로, 합법적인 타입 바이트는 3이나 5가 될 수 있습니다. 포인트 타입은 그 비트를 마스크로 지운 바이트이고, 플래그는 이 포인트에서 끝나는 세그먼트 뒤에 도형을 닫으라는 뜻입니다. 옛 PDFlibPas 핸들러는 바이트를 case문에서 단일 값과 비교했으니 타입 3과 5의 포인트는 아무것에도 맞지 않아 통째로 건너뛰어졌습니다. PolyDraw로 그린 사각형은 닫는 변을 잃었고, 마지막 포인트가 플래그를 실은 Bezier 삼 개조는 그 포인트를 잃어 이후 모든 삼 개조가 밀려나갔습니다

v3.539.39부터는 타입을 Types[i] and not PT_CLOSEFIGURE로 읽고, 닫기는 완성된 세그먼트 뒤에만 내보냅니다. 닫힌 PT_LINETO의 선 뒤, Bezier 그룹의 셋째 포인트 뒤입니다. 삼 개조의 첫째나 둘째 포인트에 플래그를 설정한 malformed 파일이 도형을 조금 닫지는 않습니다. 같은 릴리스에 관련 수정 두 건이 함께 나왔습니다:

  • 16비트 EMR_POLYDRAW16에서 모든 PT_MOVETO가 경로 전체를 다시 시작했으므로, 도형 세 개를 담은 레코드는 마지막 하나만 남겼습니다. 이제 첫 move가 경로를 시작하고 이후 move들은 서브패스를 엽니다
  • PT_MOVETO로 시작하지 않는 PolyDraw 레코드는 레코드 정의가 말하는 대로 현재 위치에서 시작하며, 앞선 m 없이 l이나 c 연산자를 쓰지 않습니다
PDFlibPas로 본 EMR_POLYDRAW 타입 바이트 해부: PT_CLOSEFIGURE는 PT_LINETO나 PT_BEZIERTO에 OR로 들어가는 플래그 비트 0이므로 합법 타입 바이트 3과 5는 디스패치 전에 and not PT_CLOSEFIGURE로 마스크해야 하며, 옛 case문은 두 바이트를 모두 건너뛰어 닫힌 도형이 마지막 변을 잃었습니다
디스패치 전에 닫기 플래그를 마스크로 지우고, 닫기는 완성된 선이나 Bezier 삼 개조 뒤에만 내보내야 합니다. 그러지 않으면 PolyDraw가 조용히 포인트를 잃습니다

EMF polyline은 PDF에서 절대 채우면 안 되는 이유는?

EMF polyline은 절대 채우면 안 됩니다. EMR_POLYLINE과 EMR_POLYPOLYLINE은 pen만으로 그리는 열린 도형이고, PDF에서 열린 경로를 채우면 암묵적으로 닫히기 때문입니다. ISO 32000-1 §8.5.3은 fill 연산자가 그리기 전에 열린 서브패스를 닫는다고 명시합니다. 그러니 세 포인트짜리 polyline에 B나 f를 내보내는 변환기는 현재 brush 색으로 채워진 삼각형을 그립니다. 차트 추세선 아래의 채워진 부채꼴이 바로 그겁니다. v3.539.41 전의 PDFlibPas는 두 폭의 polyline을 모두 brush로 채웠고, 32비트 레코드는 명시적으로 닫기까지 했습니다. 지금은 두 폭 모두 스트로크로만 끝나며, GDI의 구분은 그대로입니다. Polygon은 닫고 채우고, Polyline은 결코 그러지 않습니다

PDFlibPas 비교: EMR_POLYLINE에서 내보낸 열린 V자 polyline에서 올바른 변환기는 스트로크 연산자 S로 경로를 끝내고 선택된 brush를 무시하지만, f나 B를 내보내면 ISO 32000-1 8.5.3 아래에서 열린 서브패스가 암묵적으로 닫히며 채워진 부채꼴 차트 버그가 그려집니다
fill 연산자는 그리기 전에 열린 서브패스를 닫으므로, polyline은 서브패스에 h, f, B 없이 S로 끝나야 합니다

PolylineTo는 현재 위치에서 시작한다

EMR_POLYLINETO는 현재 위치에서 시작해 레코드의 모든 포인트를 지나 그리며, 열린 채로 있고 현재 위치는 마지막 포인트에 둡니다. 옛 핸들러에는 처음 두 포인트가 y 좌표를 공유하면 pen을 끄는 특수 케이스도 있었는데, 다시 켜는 코드는 어디에도 없어서 파일의 이후 모든 레코드가 윤곽을 잃었습니다. pen 상태는 EMR_SELECTOBJECT와 EMR_CREATEPEN의 소관이지, 드로잉 레코드 핸들러가 건드릴 일이 아닙니다. 그 특수 케이스는 v3.539.41에서 제거됐고, 레코드의 한 포인트 형태가 자기 포인트 밖을 읽는 문제도 고쳐졌습니다(v3.539.39 수정)

PolyPolyline 포인트는 counts 배열 다음부터 시작한다

32비트 EMR_POLYPOLYLINE은 nPolys개의 개수와 그 뒤의 cptl개 포인트를 저장하며, 포인트는 바이트 오프셋 32 + nPolys * 4에서 시작합니다. 함정은 RTL에 있습니다. Windows 유닛은 TEMRPolyPolyline에서 aPolyCounts와 aptl을 요소 하나짜리 배열로 선언하므로, aptl[0]이 첫 포인트인 것은 nPolys가 1일 때뿐입니다. aptl을 직접 인덱싱하는 코드는 여러 선을 가진 레코드마다 개수 값을 좌표로 읽어 버립니다. 옛 PDFlibPas 핸들러는 경계 검사도 그 엉터리 레이아웃 기준으로 잡았으니, 합법적인 multi-line 레코드는 거부되고 한 줄짜리는 아무것도 그리지 않았습니다. v3.539.41부터 PDFlibPas는 PolyPolygon 핸들러가 늘 그래 왔듯 계산한 오프셋에서 포인트 배열을 찾고, 각 polyline을 자기만의 열린 서브패스로 그린 뒤 마지막에 스트로크 한 번으로 끝냅니다. v3.539.43에서 16비트 쌍둥이도 같은 대접을 받았습니다. 이전에는 세그먼트별로 그렸기에 line join이 깨지고 선택된 NULL_PEN도 무시했습니다

기본 pen과 brush, 그리고 path 괄호

v3.539.43의 polyline 수정을 완성하는 상태 규칙 두 가지입니다:

  • 새 GDI 디바이스 컨텍스트에는 이미 BLACK_PEN과 WHITE_BRUSH가 선택되어 있으므로, EMR_SELECTOBJECT 없이 그리는 메타파일도 검은 윤곽을 그립니다. 변환기는 pen도 fill도 없이 시작해 그런 레코드에 n(경로 끝내기, 아무것도 그리지 않음)을 썼죠
  • BeginPath / EndPath 괄호 안에서 Polyline은 현재 위치를 쓰지도 갱신하지도 않으므로, 이전 도형에 이어 붙는 대신 첫 포인트에서 새 서브패스를 열어야 하고, 괄호가 스트로크되거나 채워지기 전에는 아무것도 그려서는 안 됩니다

TMetafileCanvas로 EMF 테스트 파일 만들기

이 규칙들에 대해 변환기를 검증하는 가장 빠른 방법은 위험한 호출 세 가지를 TMetafileCanvas로 하나의 enhanced metafile에 녹화하는 것입니다. 아래 드로잉은 곡선을 속이 빈 brush로 녹화한 다음 polyline에 일부러 속이 찬 노란 brush를 선택합니다. 올바른 변환기라면 polyline에 그 brush를 무시해야 하므로, 출력 PDF에 노란색이 보이면 버그입니다. PolyDraw에는 TCanvas 래퍼가 없으므로 캔버스 핸들로 Windows API를 호출하고, 타입 바이트 3과 5로 닫기 플래그를 시험합니다

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

procedure BuildPolyTestEmf(const FileName: string);
const
  // 닫힌 사각형(3 = LINETO + CLOSEFIGURE), 그다음 마지막 제어 삼 개조가
  // 5 = BEZIERTO + CLOSEFIGURE로 끝나는 닫힌 Bezier 도형
  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이 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));
      // 속이 찬 brush를 선택한 채 열린 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바이트인 빈 파일은 WMF로 치부되어 "Metafile is not valid"로 거부됩니다. 테스트 레코드 앞에 확장 필드를 포함한 108바이트 전체 헤더를 반드시 쓰세요

PDFlibPas로 EMF를 PDF에 임포트하기

PDFlibPas는 ImportEMFFromFile이나 ImportEMFFromStream으로 EMF를 임포트하며, 성공하면 0이 아닌 이미지 ID를, 실패하면 0을 반환합니다. GeneralOptions = 0은 이 글의 주제인 벡터 경로를 유지하고, 1은 메타파일을 대신 비트맵으로 래스터화합니다. FontOptions = 1은 메타파일 폰트를 non-embedded 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 = 폰트를 non-embedded 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 임포트는 form XObject가 되므로, GetPageContentToString은 save, transform, Do, restore 순서만 반환합니다. Poly* 레코드에서 만들어진 m, l, c, h, S 연산자는 압축된 form XObject 스트림 안에 삽니다. 감사하려면 저장된 파일을 PDF 오브젝트 인스펙터에서 풀고 form 스트림을 읽으세요. 위 테스트 파일이라면 polyline이 앞선 h 없이 S로 끝나고, PolyDraw 도형의 닫기 플래그마다 h가 있으며, 이 서브패스들 어디에도 f나 B가 없는 것을 확인해야 합니다. DrawImage는 임포트된 EMF를 Width와 Height 중 작은 쪽으로 균등하게 확대/축소하니, 넘기는 상자가 어긋나도 드로잉은 가로세로 비를 유지합니다

Free Pascal 타깃이라면 PDFlibPas EMF 벡터 임포터가 Free Pascal에서 어떻게 빌드되는지를 보세요. 임포터가 어디서 컴파일되든 레코드 의미는 같습니다

EMF 파서는 파일의 포인트 개수를 어떻게 다뤄야 할까?

EMF 파서는 모든 포인트 개수를 신뢰할 수 없는 입력으로 취급하고, 포인트 하나를 복사하기 전에 레코드 크기와 대조해 검사해야 합니다. EnumEnhMetaFile은 각 레코드의 nSize가 파일 안에 머무는 것만 보장합니다. cptl이 nSize와 일치하는지는 검사하지 않으니, Move로 cptl개 포인트를 복사하는 핸들러는 개수가 위조되거나 깨졌을 때 뒤따르는 레코드나 메타파일 끝을 넘어 읽어 버립니다. v3.539.39부터 PDFlibPas는 두 폭의 PolyDraw, PolyBezier, PolyBezierTo, Polyline, PolylineTo, Polygon에 대해 고정 헤더 더하기 개수 곱하기 포인트당 바이트를 nSize와 대조하고, PolyDraw 타입 바이트에는 포인트당 한 바이트를 더합니다. PolyPoly 레코드에서는 도형별 개수의 합도 선언된 총합을 넘지 않아야 하고, 포인트 0개짜리 도형은 건너뜁니다

같은 검사는 직접 파서에 복사해 넣을 만큼 짧습니다. 이 버전은 32비트 EMR_POLYPOLYLINE을 검증하고 실제 포인트 배열을 가리키는 포인터를 반환합니다:

uses
  Winapi.Windows;

// 레코드가 선언한 포인트를 실제로 담고 있지 않으면 nil을 반환
// 포인트는 counts 배열 다음, 즉 32 + nPolys * 4바이트 지점부터 시작하며
// RTL이 요소 하나짜리 배열로 선언하는 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]은 range check에 걸림
  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;

포인트 적합 검사가 먼저 돌므로, 루프가 counts 배열을 걷기 전에 그 배열이 레코드 안에 있다는 것이 확인됩니다. 산술은 Int64입니다. 32비트로 계산한 nPolys * 4와 cptl * 8은 랩어라운드로 비교를 통과해 버릴 수 있기 때문입니다

빠른 참조: EMF to PDF 변환의 EMF Poly* 규칙

  • EMR_POLYBEZIER: 포인트 0이 시작점이고, 포인트 1부터 셋씩 묶습니다. 32비트 레코드는 v3.539.41에서 수정
  • EMR_POLYLINE / EMR_POLYPOLYLINE: 열린 도형, S로 스트로크하며 h, f, B는 결코 금지. PDF fill은 열린 서브패스를 닫기 때문
  • EMR_POLYLINETO: 현재 위치에서 시작해 열린 채로 있고 현재 위치를 갱신하며, pen 상태는 절대 건드리지 않음
  • 32비트 EMR_POLYPOLYLINE: 포인트는 바이트 32 + nPolys * 4에서 시작하며 aptl[0]이 아님
  • EMR_POLYDRAW: 디스패치 전에 PT_CLOSEFIGURE를 마스크로 지우고, 완성된 세그먼트 뒤에 닫으며, 첫 포인트가 PT_MOVETO가 아니면 현재 위치에서 시작
  • 기본 디바이스 컨텍스트 상태는 BLACK_PEN 더하기 WHITE_BRUSH이며, v3.539.43 이상이 이를 따름
  • BeginPath / EndPath 안에서 각 polyline은 자기 서브패스를 열고, 괄호가 쓰이기 전에는 아무것도 그려지지 않음
  • 포인트를 복사하기 전에 모든 cptl / cpts를 64비트 산술로 nSize와 대조해 검증
  • 손으로 만든 테스트 EMF는 108바이트 전체 헤더가 필요하며, 없으면 TMetafile.LoadFromStream이 WMF로 읽음

리포트가 다른 컴포넌트를 통한다면 레코드 의미는 같게 적용됩니다. HotPDF EMF 및 WMF 벡터 임포트가 그 컴포넌트가 그라디언트와 해치 brush를 PDF 패턴으로 바꾸는 방법을 다루고, PDFlibPas의 벡터 그래픽, 셰이더, 그라디언트가 메타파일 대신 라이브러리 API로 같은 도형을 직접 그리는 법을 다룹니다

PDFlibPas v3.539.43 이상에는 위의 모든 규칙이 들어 있습니다. 자세한 내용과 평가판 다운로드는 PDFlibPas Delphi PDF 라이브러리 제품 페이지에 있습니다