Technical Article

Delphi를 사용한 PDF 벡터 그래픽: 패스 및 그라데이션

PDF를 다루는 대부분의 Delphi 코드는 이 포맷을 텍스트 덩어리와 몇 개의 비트맵을 담는 컨테이너로 취급합니다. 이러한 관점도 일리가 있지만, PDF 포맷의 가장 강력한 부분을 활용하지 못하게 만듭니다. PDF 페이지는 PostScript와 동일한 이미징 모델을 기반으로 구축된 해상도 독립적인 2D 캔버스입니다. 선, 곡선, 채워진 영역, 그라데이션 및 반복 패턴을 모두 벡터로 그릴 수 있어 어떤 배율에서도 선명함을 유지하고 장치의 기본 해상도로 인쇄됩니다. 로고, 차트, 워터마크 또는 인증서 테두리를 그리는 경우 벡터 패스(vector path)가 거의 항상 올바른 기본 단위(primitive)이며, 많은 프로그램에서 대신 사용하는 래스터화된 이미지보다 용량이 작고 선명합니다.

이 글에서는 ISO 32000-1에 정의된 벡터 모델을 살펴보고 이에 대응하는 PDFlibPas 호출을 보여줍니다. API가 사양과 긴밀하게 매핑되어 있어 하나를 이해하면 다른 하나도 자연스럽게 학습되므로, 사양을 실무적으로 이해하는 데 도움이 될 것입니다.

페이지는 패스 처리 엔진

ISO 32000-1 §8.5는 그래픽을 서로 겹치지 않는 두 단계로 나누어 설명합니다. 먼저 패스를 생성(construct)하며, 이는 시각적 결과가 없는 순수한 기하학적 형태입니다. 그런 다음 외곽선을 그리거나(stroke), 내부를 채우거나(fill), 혹은 둘 다 수행하는 단일 작업으로 해당 패스를 칠합니다(paint). 생성 단계에서는 페이지에 아무것도 나타나지 않습니다. 패스는 칠하기 연산자(painting operator)가 이를 소비할 때까지 그래픽 상태(graphics state)에 보관되는 추상적인 점과 선분의 시퀀스이며, 소비되는 시점에 렌더링된 후 폐기됩니다.

패스는 하나 이상의 하위 패스(subpath)로 구성됩니다. 하위 패스는 한 점에서 시작하여 직선, 3차 베지에 곡선, 그리고 일부 플랫폼에서는 자체 닫힌 하위 패스로 추가되는 사각형 등의 선분을 추가하면서 확장됩니다. PDFlibPas에서는 시작점을 설정하는 StartPath로 패스를 열고, AddLineToPathAddCurveToPath를 사용하여 패스를 확장합니다. 각 호출은 암시적인 현재 위치를 이동시키므로, 다음 선분은 이전 선분이 끝난 지점에서 시작합니다. ClosePath는 하위 패스의 시작점으로 되돌아가는 마지막 직선 선분을 그려 닫습니다. 이는 외곽선을 그릴 때 열려 있는 두 개의 끝 모양 대신 닫히는 꼭짓점에 실질적인 연결선(line join)을 생성하므로 중요합니다.

// A closed quadrilateral, stroked then filled
PDF.SetLineColor(0, 0, 0);
PDF.SetFillColor(0.6, 0.8, 1.0);
PDF.SetLineWidth(1.5);

PDF.StartPath(150, 100);           // open the path at the first vertex
PDF.AddLineToPath(220, 140);
PDF.AddLineToPath(180, 210);
PDF.AddLineToPath(110, 170);
PDF.ClosePath;                     // straight segment back to (150, 100)
PDF.DrawPath(2);                   // 2 = fill and stroke; path is consumed

곡선은 두 개의 베지에 제어점과 끝점을 인수로 사용하는 AddCurveToPath를 사용합니다: AddCurveToPath(CtAX, CtAY, CtBX, CtBY, EndX, EndY). 곡선은 현재 점에서 시작하여 두 제어점 방향으로 당겨지면서 (EndX, EndY)까지 이어집니다. 원호(circular arc)는 AddArcToPath(CenterX, CenterY, TotalAngle)를 통해 그릴 수 있습니다. 이때 반지름은 현재 점과 중심점 사이의 거리를 기준으로 계산되며, 엔진은 원호를 베지에 선분의 체인으로 출력합니다. 직사각형은 AddBoxToPath(Left, Top, Width, Height)라는 지름길을 제공하며, 이는 이전 StartPath 호출 없이도 고유한 닫힌 사각형 하위 패스를 추가합니다.

두 가지 채우기 규칙과 두 규칙의 결과가 다른 이유

스스로 교차하거나 내부 루프를 포함하는 패스를 채울 때, 렌더러는 어떤 영역이 도형의 내부이고 어떤 영역이 구멍인지를 결정할 규칙이 필요합니다. ISO 32000-1 §8.5.3.3은 두 가지 규칙을 정의하며, 동일한 기하학적 구조라도 다르게 칠할 수 있습니다. nonzero winding 규칙은 테스트 점에서 무한대로 뻗어 나가는 반직선의 교차 방향 횟수를 계산합니다. 선분이 왼쪽에서 오른쪽으로 교차하면 1을 더하고, 반대 방향으로 교차하면 1을 뺍니다. 총합이 0이 아니면 해당 점은 내부로 판정됩니다. even-odd 규칙은 방향을 무시하고 단순히 교차 횟수만 계산하여 횟수가 홀수일 때 해당 점을 내부로 판정합니다.

두 규칙이 다른 결과를 내는 대표적인 사례는 도넛이나 와셔와 같이 구멍이 뚫린 도형입니다. 외곽 경계선과 그 내부에 안쪽 경계선을 그린다고 가정해 봅시다. even-odd 규칙을 적용하면 두 경계선 사이의 임의의 점은 교차 횟수가 1회이고 안쪽 루프 내부의 점은 교차 횟수가 2회가 되므로, 안쪽 루프는 항상 구멍으로 뚫립니다. 반면 nonzero winding 규칙에서는 안쪽 루프가 외곽 루프와 반대 방향으로 회전할 때만 구멍이 뚫립니다. 동일한 방향으로 회전하면 회전 방향이 상쇄되는 대신 강화되어 내부 영역이 완전히 채워집니다. 단일 자기 교차 외곽선으로 그려진 별 모양(오각별)도 동일한 차이를 보여줍니다: even-odd 규칙은 중앙의 오각형을 비워두는 반면, nonzero winding 규칙은 이를 채웁니다.

PDFlibPas는 플래그가 아닌 호출하는 그리기 메서드에 따라 규칙을 선택합니다. DrawPath는 nonzero winding 규칙으로 채우고, DrawPathEvenOdd는 even-odd 규칙으로 채웁니다. 두 메서드 모두 동일한 정수 모드를 사용합니다: 0은 외곽선만 그리고, 1은 채우기만 하며, 2는 채우기와 외곽선 그리기를 동시에 수행합니다. even-odd 규칙은 하위 패스의 회전 방향을 관리할 필요가 없기 때문에 구멍을 뚫는 작업에 더 편리합니다.

// Same two boxes, two fill rules, two different results.
// Nonzero winding: both boxes wind the same way, so the inner one
// does NOT cut a hole and the whole outer box fills solid.
PDF.SetFillColor(0.2, 0.4, 0.8);
PDF.AddBoxToPath(100, 100, 200, 120);   // outer
PDF.AddBoxToPath(140, 130, 120,  60);   // inner
PDF.DrawPath(1);                         // 1 = fill, nonzero winding

// Even-odd: the inner box is crossed an even number of times,
// so it punches a clean rectangular hole through the outer box.
PDF.SetFillColor(0.2, 0.4, 0.8);
PDF.AddBoxToPath(100, 300, 200, 120);   // outer
PDF.AddBoxToPath(140, 330, 120,  60);   // inner cut-out
PDF.DrawPathEvenOdd(1);                  // 1 = fill, even-odd

선을 따라 색상이 변하는 선형 그라디언트

단색 채우기는 전체 영역에 걸쳐 하나의 색상 값만 가집니다. 그라데이션은 색상을 연속적으로 변경하며, 가장 단순한 종류는 축형(axial) 또는 선형(linear) 그라데이션입니다. ISO 32000-1 §8.7.4.5는 이를 Type 2 축형 셰이딩(axial shading)으로 규정합니다. 축을 정의하는 두 점을 지정하고 첫 번째 점에는 시작 색상을, 두 번째 점에는 끝 색상을 부여하면 렌더러가 해당 축을 따라 색상을 보간(interpolate)합니다. 채워진 영역의 모든 점은 축에 대한 수직 투영 색상을 띠므로, 그라데이션은 두 점 사이의 선에 직각을 이루는 띠 형태로 표시됩니다.

PDFlibPas에서 그라데이션은 한 번 생성한 후 활성 페인트로 선택하여 사용하는 이름이 지정된 문서 리소스입니다. NewRGBAxialShader가 이를 등록합니다. 시그니처는 NewRGBAxialShader(ShaderName, StartX, StartY, StartRed, StartGreen, StartBlue, EndX, EndY, EndRed, EndGreen, EndBlue, Extend)입니다: 두 축의 끝점, 0에서 1 범위의 값으로 구성된 양쪽 끝의 RGB 삼중값(triple), 그리고 Extend 플래그를 받습니다. Extend1로 설정하면 끝 색상이 축의 끝점 범위를 넘어 고정된 색상으로 계속 확장되어 채워집니다. 이는 축을 벗어난 영역의 모서리가 칠해지지 않은 채로 남지 않도록 할 때 유용합니다. 0으로 설정하면 연장하지 않고 남겨둡니다. 셰이더가 생성되면 채우기 영역에는 SetFillShader로, 외곽선에는 SetLineShader로, 텍스트에는 SetTextShader로 바인딩합니다. 이 바인딩은 이후 이어지는 그리기 호출 동안 활성 상태를 유지하므로, 다음에 그리는 패스는 단색 대신 그라데이션이 적용됩니다.

// Define a vertical gradient once: blue at the bottom to white at the top.
PDF.NewRGBAxialShader('panelGrad',
  0, 100,   0.10, 0.25, 0.55,    // start point and start RGB
  0, 260,   1.00, 1.00, 1.00,    // end point and end RGB
  1);                            // 1 = extend ends as solid color

// Select the gradient as the fill, then paint a rectangle with it.
PDF.SetFillShader('panelGrad');
PDF.AddBoxToPath(80, 100, 300, 160);
PDF.DrawPath(1);                 // 1 = fill, now filled by the shader

여기서 축은 고정된 x를 기준으로 y=100에서 y=260까지 수직이므로, 색상 띠가 수평으로 배치되어 직사각형의 하단 파란색에서 상단 흰색으로 색상이 변합니다. 셰이더는 이름으로 관리되므로 하나의 정의로 페이지의 여러 도형을 채울 수 있으며, 단색으로 다시 전환하려면 다음 패스를 그리기 전에 SetFillColor를 다시 호출하면 됩니다.

타일링 패턴의 셀 반복

그라데이션이 단일 색상을 부드럽게 변화시키는 반면, 타일링 패턴은 작은 그래픽 조각을 영역 전체에 반복해서 배열합니다. ISO 32000-1 §8.7.3.1은 타일링 패턴을 독립적인 콘텐츠 조각인 패턴 셀(pattern cell)로 정의하며, 렌더러는 이를 고정된 격자 위에 복제하여 칠해지는 영역을 타일처럼 채웁니다. 이를 통해 엔지니어링 도면의 해칭(hatching), 헤더 뒤의 반복되는 브랜드 모티프, 또는 영역의 크기에 관계없이 벡터 수준의 선명함을 유지하면서 용량을 최소화하는 질감 배경 등을 구성할 수 있습니다. 셀이 한 번만 저장되고 재참조되기 때문입니다.

PDFlibPas는 캡처된 페이지 콘텐츠에서 패턴 셀을 빌드합니다. CapturePage로 페이지나 영역을 캡처하고, NewTilingPatternFromCapturedPage(PatternName, CaptureID)를 사용하여 이 캡처를 명명된 패턴으로 변환한 다음, SetFillTilingPattern(PatternName)으로 해당 패턴을 현재 채우기 패턴으로 선택합니다. 이후 채우기를 적용하는 모든 패스는 단색 대신 반복되는 셀로 칠해집니다. 이는 셰이더 채우기와 유사하게 작동하지만 타일 형태의 셀을 페인트 소스로 사용합니다. 이 시퀀스는 단일 호출보다 구조가 복잡하므로 캡처 단계가 낯설다면 2단계 작업으로 간주하십시오: 먼저 캡처된 셀을 생성하고, 타일링하려는 영역을 그리기 전에 이를 이름으로 묶어 채우기 속성에 바인딩하십시오.

기본 객체들을 하나로 결합하기

이러한 빌딩 블록들은 서로 직접 결합할 수 있습니다. 채워진 베지에 모양은 DrawPath로 칠한 곡선 패스입니다. 동일한 외곽선에 안쪽 루프를 추가하고 DrawPathEvenOdd로 칠하면, winding 규칙 채우기에서는 채워졌을 영역에 구멍이 나타납니다. 그라데이션으로 채워진 직사각형은 셰이더에 바인딩된 사각형입니다. 아래 예제는 두 가지 채우기 규칙의 차이를 한 페이지에서 시각적으로 확인할 수 있도록 세 가지 요소를 순서대로 그리고, 그 아래에 그라데이션 패널을 배치합니다.

// 1. A filled Bezier shape (nonzero winding).
PDF.SetFillColor(0.85, 0.30, 0.25);
PDF.StartPath(120, 480);
PDF.AddCurveToPath(160, 560, 240, 560, 280, 480);   // top lobe
PDF.AddCurveToPath(240, 420, 160, 420, 120, 480);   // bottom lobe
PDF.ClosePath;
PDF.DrawPath(1);                                     // 1 = fill

// 2. The same outline, plus an inner loop, filled even-odd to show a hole.
PDF.SetFillColor(0.85, 0.30, 0.25);
PDF.StartPath(120, 300);
PDF.AddCurveToPath(160, 380, 240, 380, 280, 300);
PDF.AddCurveToPath(240, 240, 160, 240, 120, 300);
PDF.ClosePath;
PDF.MovePath(180, 300);                              // new subpath: the hole
PDF.AddArcToPath(200, 300, 360);                     // a full circle
PDF.ClosePath;
PDF.DrawPathEvenOdd(1);                              // hole is punched out

// 3. A rectangle filled with an axial gradient.
PDF.NewRGBAxialShader('footerGrad',
  60, 100,  0.95, 0.55, 0.10,
  60, 200,  0.20, 0.10, 0.40,
  1);
PDF.SetFillShader('footerGrad');
PDF.AddBoxToPath(60, 100, 340, 100);
PDF.DrawPath(1);

기억해야 할 두 가지 세부 사항이 있습니다. 첫째, 그리기 호출 시점에 채우기 규칙이 결정되므로, DrawPathDrawPathEvenOdd 중 하나를 선택하는 것은 nonzero winding과 even-odd 중 하나를 선택하는 것과 같으며, 구멍이 있는 도형의 경우 even-odd 규칙을 사용하면 하위 패스의 방향을 고민할 필요가 없습니다. 둘째, 그래픽 상태(graphics state)는 그리는 시점에 샘플링되므로 그리기 호출 전에 색상, 선 두께 및 셰이더 바인딩을 설정해야 합니다. 엔진이 이를 기준으로 렌더링하기 때문입니다. 먼저 구성하고, 상태를 설정한 다음, 마지막에 그리는 순서를 따르면 벡터 모델을 항상 예측 가능하게 제어할 수 있습니다.

이후 단계로 적합한 주제는 기존 문서에서 벡터와 텍스트를 다시 읽어오는 작업이며, 이는 텍스트, 이미지 및 글꼴 추출에 대한 문서에서 다룹니다. 또한 동일한 그리기 모델을 Windows 디바이스 컨텍스트로 렌더링하여 화면 프리뷰 및 인쇄를 구현하는 방법은 인쇄 및 프리뷰 연습 가이드에서 다룹니다. 여기에 설명된 패스, 셰이더 및 패턴 호출은 이 블로그의 다른 곳에서 다루는 텍스트, 이미지, 폼 및 서명 API와 함께 Delphi용 PDF 라이브러리의 일부로 제공됩니다.