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를 하나씩 내보냅니다
직접 파서를 만든다면: 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연산자를 쓰지 않습니다
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은 결코 그러지 않습니다
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 라이브러리 제품 페이지에 있습니다