기술 문서

델파이에서 HotPDF 원시 요소(Primitives)로 PDF에 차트 그리기

화면 차트 컨트롤에서 96 DPI 비트맵으로 복사해 온 차트는 PDF 뷰어의 100% 줌에서는 멀쩡해 보입니다. 400%로 확대하거나 페이지를 600 DPI 레이저 프린터로 보내면 막대 가장자리가 물먹고, 축 레이블은 회색 얼룩으로 흐려지며, 파일에는 정작 십여 개의 직사각형에 불과한 것을 위해 수백 킬로바이트의 픽셀이 실립니다. 대신 벡터 경로로 그리면 같은 차트가 모든 줌 배율과 인쇄 해상도에서 또렷합니다. 막대, 축, 레이블이 PDF 경로 및 텍스트 연산자여서 뷰어가 필요한 배율로 다시 렌더링하기 때문입니다

벡터 출력은 레이아웃 작업을 여러분 코드로 옮깁니다. 누군가는 각 막대, 눈금, 레이블이 어디 착지하는지 계산해야 하고, 막대·꺾은선·원형 차트에서 그 계산은 좌표 사상 하나와 직사각형·선분·호를 도는 루프 몇 개입니다. 사상을 한 번 올바로 잡으면 루프는 짧습니다

HotPDF의 DrawChart인가, 손으로 그린 원시 요소인가

HotPDF는 만들어진 차트 렌더러를 함께 제공하며, 표준 보고서 차트라면 먼저 시도할 것입니다. THPDFChart를 만들고, AddSeries로 계열을 추가하고(hckLine, hckBar, hckArea, hckPie, hckScatter 종류), AddPoint로 채운 다음, 대상 직사각형과 함께 THPDFPage.DrawChart를 호출합니다. 렌더러가 축 범위, 격자, 눈금 레이블, 범례, 테마 색을 알아서 처리하고, 구조 부모를 받는 오버로드는 데이터로부터 만든 대체 텍스트를 단 태그 figure로 차트를 기록합니다. 문서가 접근성 검사를 통과해야 한다면 이게 중요해집니다

var
  Chart: THPDFChart;
  Sales: THPDFChartSeries;
begin
  Chart := THPDFChart.Create;
  try
    Chart.Title := 'Quarterly sales';
    Chart.LegendPosition := hclBottom;
    Sales := Chart.AddSeries(hckBar, 'Sales');
    Sales.AddPoint(1, 420, 'Q1');
    Sales.AddPoint(2, 515, 'Q2');
    Sales.AddPoint(3, 388, 'Q3');
    Sales.AddPoint(4, 610, 'Q4');
    // 페이지 단위의 Rectangle: left, top, width, height
    HotPDF1.CurrentPage.DrawChart(Chart, 60, 120, 470, 280);
  finally
    Chart.Free;
  end;
end;

다만 렌더러의 어휘는 고정되어 있습니다. 여러 막대 계열은 각 범주 안에서 나란히 앉고, 원형 차트는 차트의 유일한 계열이어야 하며, 생김새는 세 테마와 계열별 색에서 나옵니다. 누적 막대, 막대 뒤를 음영 처리한 목표 밴드, 단일 계열 안에서 막대마다 다른 브랜드 색, 12시 방향에서 시작해 시계 방향으로 도는 원형 차트 — 디자인이 이 중 하나라도 요구하는 순간, 렌더러를 휘어려는 것보다 페이지 캔버스로 직접 그리는 편이 일이 적습니다. 그 원시 요소란 Rectangle, MoveTo, LineTo, Pie, Fill, Stroke, TextOut이며, 캔버스 드로잉 워크스루가 다루는 바로 그 호출들입니다

막대 차트가 거꾸로 나오는 이유는?

HotPDF 페이지 좌표는 원점을 왼쪽 위에 두고 Y를 아래로 키웁니다. VCL 캔버스와 같은 좌표 프레임입니다. PDF 사용자 공간 자체는 반대로 흐르고(원점 왼쪽 아래, Y 위로 향함, ISO 32000-1 §8.3.2.3), HotPDF는 콘텐츠 스트림을 쓰면서 모든 좌표를 그 공간으로 뒤집습니다. 반면 차트 값은 위로 자랍니다. 스케일한 값을 Y에 곧바로 넣으면 가장 큰 막대가 고드름처럼 플롯 천장에서 매달립니다

해법은 식 하나입니다. 플롯 직사각형을 정해 0을 아래 모서리에, 스케일 꼭대기를 위 모서리에 사상하고, 모든 값을 페이지에 닿기 전에 그 사상으로 통과시킵니다. 0에서 MaxValue까지 스케일의 값 V에 대해 페이지 Y는 Plot.Bottom - (V / Plot.MaxValue) * Plot.Height이고, 여기서 Plot.Bottom은 베이스라인입니다. 뒤에서 나올 막대 차트 코드는 이것을 작은 ValueToY 도우미로 감싸고 다른 모든 루틴이 재사용합니다

HotPDF: 화면 좌표와 HotPDF 페이지 좌표는 top-left 원점과 아래로 자라는 Y를 공유하는 반면 차트 값은 위로 자라며, ValueToY가 데이터 값을 그 Y-down 페이지 공간으로 뒤집어 매핑
HotPDF는 익숙한 화면 좌표계를 유지합니다. 원점이 왼쪽 위에 있고 Y가 아래로 자라며, 하나의 ValueToY 식이 위로 자라는 모든 데이터 값을 페이지 공간으로 뒤집어 사상합니다

그 식 안에는 두 가지 선택이 숨어 있습니다. MaxValue는 가장 큰 데이터 점 위의 자리수 좋은(round) 수, 다음 10 또는 100의 배수여야 합니다. 그래야 가장 큰 막대가 위 모서리에 닿지 않고 격자선 레이블이 0, 203.5, 407 대신 0, 200, 400으로 읽힙니다. 그리고 스케일은 의도적으로 0에서 시작합니다. 막대 길이가 곧 값이므로, 350에서 시작하는 값 축은 실제 격차가 8% 미만인데도 388 막대를 420 막대의 절반 조금 넘게 보이게 만듭니다. 내장 렌더러가 막대와 영역 계열에 0을 범위로 끌어들이는 것도 정확히 이 이유입니다. 손으로 그리는 코드에서도 같은 규칙을 지키세요

단위는 사상의 나머지 절반입니다. 기본 Resolution 72에서는 페이지 단위 하나가 PDF 포인트 하나(1/72인치)입니다. 해상도를 바꾸면 좌표, 선 폭, 텍스트 측정이 모두 새 단위로 바뀌는데, 해상도와 드로잉 단위 글이 이를 자세히 다룹니다. DrawChart는 대상 직사각형을 페이지 단위로 받지만 자기 마진, 폰트 크기, 선 폭은 어떤 해상도에서든 포인트로 배치합니다

막대 차트는 사각형을 그리는 루프입니다

사상이 자리 잡으면 막대 차트는 루프입니다. 플롯 폭을 범주당 슬롯 하나로 나누고, 이웃 막대가 닿지 않게 간격을 남기고, 각 막대를 ValueToY 꼭대기에서 베이스라인까지 내려오는 채워진 직사각형으로 그립니다. Rectangle은 왼쪽 위 모서리와 폭, 높이를 받으므로 이 사상에 그대로 들어맞습니다:

HotPDF: 막대 차트 해부도로 플롯 폭에서 나눈 슬롯과 막대 사이 간격, Plot.Bottom에서 위로 자라는 채워진 Rectangle 막대, 베이스라인 아래 범주 레이블, 막대 높이를 타고 붙는 값 레이블, ValueToY를 재사용하는 축 눈금을 보여줌
각 막대는 자신의 슬롯 안에서 Plot.Bottom부터 위로 자라는 채워진 Rectangle 하나이고, 1/4 슬롯 간격은 폭에 비례하며, 축 눈금은 ValueToY를 재사용해 막대와 격자선이 일치합니다
type
  TPlotArea = record
    Left, Bottom, Width, Height: Single;  // 페이지 단위, Bottom은 베이스라인 Y
    MaxValue: Single;                     // 값 스케일의 꼭대기
  end;

// 자체 도우미: 값이 클수록 높이 앉는데, Y-down 페이지에서
// 높이 앉는다는 것은 더 작은 Y를 뜻합니다
function ValueToY(const Plot: TPlotArea; V: Single): Single;
begin
  Result := Plot.Bottom - (V / Plot.MaxValue) * Plot.Height;
end;

procedure DrawBarChart(Page: THPDFPage; const Plot: TPlotArea;
  const Values: array of Single; const Labels: array of string;
  const BarColors: array of TColor);
var
  I, Count: Integer;
  SlotW, BarW, BarX, BarTop, Gap, GridV, GridY: Single;
begin
  Count := Length(Values);
  SlotW := Plot.Width / Count;
  Gap := SlotW * 0.25;              // 슬롯의 1/4, 좌우로 나눔
  BarW := SlotW - Gap;

  // 막대가 덧칠하도록 점선 격자선을 먼저 그립니다
  Page.SetLineWidth(0.5);
  Page.SetRGBStrokeColor(RGB(200, 200, 200));
  Page.SetDash([2, 2], 0);
  GridV := Plot.MaxValue / 4;
  while GridV <= Plot.MaxValue do
  begin
    GridY := ValueToY(Plot, GridV);
    Page.MoveTo(Plot.Left, GridY);
    Page.LineTo(Plot.Left + Plot.Width, GridY);
    Page.Stroke;
    GridV := GridV + Plot.MaxValue / 4;
  end;
  Page.NoDash;

  Page.SetFont('Arial', [], 9);
  for I := 0 to Count - 1 do
  begin
    BarX := Plot.Left + I * SlotW + Gap / 2;
    BarTop := ValueToY(Plot, Values[I]);

    Page.SetRGBFillColor(BarColors[I mod Length(BarColors)]);
    Page.Rectangle(BarX, BarTop, BarW, Plot.Bottom - BarTop);
    Page.Fill;

    // TextOut은 텍스트 꼭대기를 Y에 놓습니다
    Page.SetRGBFillColor(clBlack);
    Page.TextOut(BarX, Plot.Bottom + 4, 0, Labels[I]);
    Page.TextOut(BarX, BarTop - 12, 0, FormatFloat('0', Values[I]));
  end;

  // 베이스라인은 마지막에, 막대 밑동 위에 앉도록
  Page.SetLineWidth(1.0);
  Page.SetRGBStrokeColor(clBlack);
  Page.MoveTo(Plot.Left, Plot.Bottom);
  Page.LineTo(Plot.Left + Plot.Width, Plot.Bottom);
  Page.Stroke;
end;

순서가 여기서 레이어링 모델의 전부입니다. PDF는 화가 모델을 쓰므로 콘텐츠 스트림의 뒤 연산자가 앞 연산자 위에 칠하고, 그래서 격자선이 먼저, 그 위에 막대, 베이스라인이 마지막입니다. 격자선 블록을 루프 아래로 옮기면 점선이 모든 막대를 가로질러 그려집니다

간격은 고정 포인트 수가 아니라 슬롯의 비율입니다. 470포인트 플롯에 범주 넷이면 슬롯마다 117.5포인트이고 막대 사이에 약 29포인트의 공기가 생깁니다. 범주 마흔이면 같은 코드가 3포인트 미만을 남기는데, 그래도 별개 막대로 읽힙니다. 고정 12포인트 간격이라면 마흔 범주에서 470포인트 중 480포인트를 삼켜 막대 폭이 음수가 됩니다

부딪히지 않는 축 레이블

손으로 그린 차트가 가장 먼저 무너지는 곳은 레이블이고, 거의 늘 같은 방식으로 무너집니다. 화면에서 들어가던 텍스트가 페이지에서는 공간을 넘칩니다. 개발자는 "Sep"으로 시험했는데 독일어 보고서가 9포인트 폰트로 "September"나 "Dezember"를 찍기 때문입니다. 값 축에는 같은 문제의 더 조용한 변형이 있습니다. 고정 X 오프셋의 왼쪽 정렬 눈금 레이블은 숫자 길이가 바뀌는 순간 삐뚤빼뚤해지고, "1,200" 레이블이 "800"이 비켜 주던 축선에 부딪힙니다

추측 대신 측정하세요. TextWidth는 현재 폰트에서 문자열의 폭을 페이지 단위로 반환하고, TextHeight는 현재 크기에서 폰트의 어센트를 반환합니다. 각 눈금 레이블을 축에 오른쪽 정렬하고 눈금 중앙에 놓습니다:

procedure DrawValueAxis(Page: THPDFPage; const Plot: TPlotArea;
  TickCount: Integer);
var
  I: Integer;
  TickV, TickY: Single;
  S: string;
begin
  Page.SetLineWidth(1.0);
  Page.MoveTo(Plot.Left, Plot.Bottom);
  Page.LineTo(Plot.Left, Plot.Bottom - Plot.Height);
  Page.Stroke;

  Page.SetFont('Arial', [], 8);
  for I := 0 to TickCount do
  begin
    TickV := (Plot.MaxValue / TickCount) * I;
    TickY := ValueToY(Plot, TickV);
    Page.MoveTo(Plot.Left - 4, TickY);        // 축 바깥의 짧은 눈금
    Page.LineTo(Plot.Left, TickY);
    Page.Stroke;

    S := FormatFloat('#,##0', TickV);
    Page.TextOut(Plot.Left - 7 - Page.TextWidth(AnsiString(S)),
      TickY - Page.TextHeight(AnsiString(S)) / 2, 0, S);
  end;
end;

범주 레이블에는 같은 측정이 가로 텍스트를 포기할 시점을 알려줍니다. 아무것도 그리기 전에 가장 넓은 레이블을 슬롯 폭과 비교하세요. 안 들어가면 폰트를 한두 포인트 줄이고, 그래도 안 들어가면 TextOut의 각도 인자에 90을 넣어 레이블을 세워 세운 뒤, 가장 긴 것을 위해 베이스라인 아래 충분한 마진을 남깁니다. 보고서가 실제로 만들어낼 가장 넓은 레이블로 시험하세요. 지역화 빌드의 월 이름에서 그것이 영어인 경우는 드뭅니다

꺾은선 차트는 같은 사상을 재사용합니다

꺾은선 차트는 점을 잇는 방식만 바꿉니다. 데이터를 한 번 걷고, 각 값을 ValueToY로 바꾸고, 점들을 하나의 경로로 잇습니다. 첫 점은 MoveTo, 이후 모든 점은 LineTo, 끝에 Stroke 한 번입니다. 점들을 이미 배열로 갖고 있다면 Polyline이 array of THPDFCurrPoint를 받아 새 경로를 열고 스트로크는 여러분에게 맡깁니다. 점이 둘 미만이면 예외를 일으키므로, 읽힌 값 하나짜리 계열은 선 대신 마커가 필요합니다

간격이 같은 플롯 위의 막대 차트와 꺾은선 차트가 갈라지는 지점입니다. 막대는 폭을 범주 수로 나눕니다. 막대마다 슬롯을 소유하기 때문입니다. 꺾은선은 폭을 구간 수, Count - 1로 나눕니다. 첫 점과 끝 점이 플롯 모서리에 앉기 때문입니다. 둘을 겹치면 꺾은선 점들이 막대 중심 대신 슬롯 경계에 착지합니다. 막대 위에 꺾은선을 얹을 때는 점들을 슬롯 중심, Plot.Left + I * SlotW + SlotW / 2에 놓고 Count - 1 간격을 완전히 버리세요. 점마다 마커를 찍으려면 선을 스트로크한 뒤 작은 Circle과 Fill이면 충분합니다

12시 방향에서 시계 방향으로 Pie를 돌리는 원형 조각

원형 조각(wedge)은 반지름 둘과 호 하나입니다. 손으로 만들면 중심에서 시작해 테두리를 따라 몇 도 간격의 짧은 LineTo 세그먼트를 밟고 다시 중심으로 닫는데, 아래 그림의 구성이 바로 그것입니다. 동작하지만 270도 조각은 백 개가 넘는 경로 세그먼트로 부풀고, 테두리는 강한 줌에서 여전히 다각형입니다

HotPDF: 파이 조각 구성으로 원 중심에서 MoveTo로 시작해, 각 조각의 스윕 각도를 따라 약 2도 간격의 작은 LineTo 세그먼트를 호를 따라 전진한 뒤 중심으로 닫아 Fill이 솔리드 부채꼴을 칠함
부채꼴은 중심에서 MoveTo로 시작해 원호를 따라 약 2도 간격의 작은 LineTo 스텝을 밟고 다시 닫아 Fill이 단단히 칠하게 합니다

대신 Pie를 쓰세요. THPDFPage.Pie는 GDI 관례를 따릅니다. 바운딩 직사각형, 시작 각도, 끝 각도(도 단위), 0은 동쪽, 90은 북쪽, 양의 각도는 반시계 방향입니다. 호를 최대 90도짜리 베지어 세그먼트로 만들고, 중심으로 선을 긋고, 경로를 닫으므로 테두리는 진짜 곡선으로 남고 꽉 찬 조각도 연산자 한 줌으로 끝납니다. 실무 원형 차트는 보통 12시 방향에서 시작해 시계 방향으로 도는데, GDI 식으로 말하면 90에서 시작해 각도를 내려가는 것입니다:

procedure DrawPieChart(Page: THPDFPage; CX, CY, Radius: Single;
  const Values: array of Double; const Colors: array of TColor);
var
  I: Integer;
  Total, Sweep, Angle: Double;
begin
  Total := 0;
  for I := 0 to High(Values) do
    if Values[I] > 0 then
      Total := Total + Values[I];
  if Total <= 0 then
    Exit;

  Angle := 90;                            // 12시 방향
  Page.SetRGBStrokeColor(clWhite);
  Page.SetLineWidth(0.8);
  for I := 0 to High(Values) do
  begin
    if Values[I] <= 0 then
      Continue;                           // 아래 설명: 0이나 음수 스윕은 절대 넘기지 않습니다
    Sweep := Values[I] / Total * 360;
    Page.SetRGBFillColor(Colors[I mod Length(Colors)]);
    // Angle - Sweep에서 Angle까지 반시계로 가는 것이 현재 위치에서
    // 시계 방향 쪽에 앉는 조각입니다
    Page.Pie(CX - Radius, CY - Radius, CX + Radius, CY + Radius,
      Angle - Sweep, Angle);
    Page.FillAndStroke;                   // 흰 스트로크가 조각들을 갈라놓습니다
    Angle := Angle - Sweep;
  end;
end;

Continue 가드가 가장 중요한 줄입니다. Pie는 시작 각도보다 작은 끝 각도를 360을 넘어 감기는 스윕, 즉 GDI 독법으로 취급하는데, 조각이 0도 선을 가로지를 수 있게 해 주는 것이 바로 이것입니다. 소스 데이터의 환불 행이나 정정 항목은 음수를 주고, 계산된 끝 각도가 시작 아래로 떨어지며, 조각은 그 전에 그려진 모든 것 위에 거의 꽉 찬 원판으로 감깁니다. 0 값은 덜하지만 여전히 보입니다. 닫힌 경로가 반지름 하나로 무너지고, 흰 분리 스트로크가 테두리에서 중심까지의 갈래 선으로 그려집니다. 둘 다 루프 전에 걸러 내고, 음수 금액을 보고서가 어떻게 보여줄지는 별도로 결정하세요. 원형 차트는 그것을 전혀 보여줄 수 없습니다

디자인이 원을 고집하지 않는다면, 수평 누적 막대 하나가 Rectangle 호출을 나란히 놓는 것만으로 같은 점유율을 보여주고, 독자는 각도보다 길이를 더 정확히 비교합니다. 범주가 다섯여섯 개를 넘는 부분-전체 그림에는 이쪽이 더 나은 차트이고, 위의 막대 차트 코드가 이미 대부분의 일을 해 줍니다

이 글의 모든 차트는 HotPDF Delphi Component의 페이지 캔버스 호출로 만들었고, 표준 꺾은선, 막대, 영역, 원형, 분산 레이아웃에는 DrawChart를 쓸 수 있습니다. 빌드 머신에 차트 라이브러리를 설치하지 않고 Delphi나 C++Builder에서 전부 동작합니다