기술 문서

HotPDF 원시 요소로 PDF에 차트 그리기

HotPDF에는 차트 객체가 없습니다. TPDFChart도, AddBarSeries도, 숫자 배열을 받아서 렌더링된 그래프를 반환하는 그 어떤 것도 없습니다. 대신 이 컴포넌트가 제공하는 것은 모든 PDF 그리기 모델이 사용하는 것과 동일한 로우 레벨 어휘, 즉 사각형, 선, 원, 채우기(fill), 테두리(stroke), 그리고 정확한 좌표에 배치된 텍스트를 가진 페이지 캔버스(canvas)입니다. 따라서 HotPDF 문서의 차트는 요청하는 것이 아니라 구축하는 것입니다. 듣기보다 별로 힘들지 않습니다. 좌표 계산(coordinate math)을 한 번 작성하고 나면 막대 차트는 사각형을 반복하는 루프가 되고, 꺾은선형 차트는 폴리라인(polyline)이 되며, 원형 차트는 호(arc)들의 부채꼴이 되고, 결과물의 모든 픽셀을 직접 제어하게 됩니다

이것이 중요한 이유는 사람들이 가장 먼저 의지하는 대안, 즉 화면 차트 컨트롤을 비트맵으로 래스터화(rasterize)하여 이미지를 페이지에 붙여넣는 방식은 화면 해상도에 고정된 차트를 제공하여 인쇄 시 흐릿하게 보이고 파일 크기를 팽창시키기 때문입니다. HotPDF의 벡터 원시 요소로 차트를 그리면 막대와 축이 픽셀이 아닌 실제 PDF 경로 연산자이기 때문에 모든 줌 레벨과 모든 인쇄 DPI에서 선명한 출력을 유지합니다. 그에 대한 비용은 레이아웃을 직접 처리해야 한다는 것입니다. 이 과정은 몇 가지 기법으로 요약됩니다: 모든 사람을 당황케 하는 하나의 좌표 반전(coordinate flip), 해결된 막대 차트, 꺾은선형 차트를 위한 폴리라인 요령, 그리고 원형 조각(pie slices)을 위한 호(arc) 계산입니다

유일하게 어려운 부분은 좌표계입니다

화면 그래픽은 왼쪽 상단에 원점을 두고 Y는 아래로 증가합니다. PDF는 그 반대입니다. 원점은 페이지의 왼쪽 하단 모서리에 있고 Y는 포인트(1/72 인치) 단위로 측정되어 위로 증가합니다. TextOut, Rectangle, MoveTo, LineTo, Circle 등 HotPDF의 모든 그리기 호출은 이 하단-좌측(bottom-left), 상향-Y(Y-up) 규칙을 사용합니다. 화면 그래픽 본능을 그대로 적용하면 첫 차트가 거꾸로 그려지고 페이지의 하단 가장자리를 벗어나게 됩니다

따라서 모든 차트의 진짜 핵심은 데이터 값을 상향-Y를 고려한 Y 좌표로 바꾸는 단 하나의 매핑에 있습니다. 플롯(plot) 사각형(차트가 들어갈 영역을 지정하는 네 개의 숫자)을 정한 다음, 데이터에서 가장 작은 값을 하단 가장자리에 매핑하고 가장 큰 값을 상단에 매핑하세요. 0부터 MaxValue까지 실행되는 눈금에서 값 V인 막대의 상단 가장자리는 PlotBottom + (V / MaxValue) * PlotHeight이며 막대는 PlotBottom에서 위로 자라납니다. 이 단일 표현식만 맞추면 나머지는 그저 부기(bookkeeping)일 뿐입니다. 아래의 도우미 코드는 플롯 기하학을 유지하고 변환을 수행하므로 그리기 코드가 기본 산술 연산을 두 번 건드리지 않게 합니다:

type
  TPlotArea = record
    Left, Bottom, Width, Height: Single;  // PDF points, bottom-left origin
    MaxValue: Single;                     // top of the value scale
  end;

// Map a data value to its Y coordinate inside the plot, Y growing upward.
function ValueToY(const Plot: TPlotArea; V: Single): Single;
begin
  Result := Plot.Bottom + (V / Plot.MaxValue) * Plot.Height;
end;

한 가지 판단이 필요한 부분은 MaxValue에 숨어 있습니다. 만약 이것을 정확히 가장 큰 데이터 포인트로 설정하면 가장 높은 막대가 플롯의 상단 가장자리에 닿아서 잘린 것처럼 보입니다. 이 값을 최대값보다 큰 깔끔한 숫자(예를 들어 다음 10 또는 100의 배수)로 반올림하여 차트에 여유 공간을 확보하고, 눈금선 레이블이 데이터가 우연히 기록한 최고점이 아닌 딱 떨어지는 숫자로 읽히게 하세요

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

매핑이 해결되면 막대 차트는 저절로 작성됩니다. 플롯 너비를 각 카테고리별로 슬롯으로 나누고, 막대가 서로 닿지 않도록 간격을 둡니다. 그런 다음 높이가 ValueToY에서 파생된 채워진 사각형으로 각 막대를 그립니다. HotPDF의 Rectangle은 왼쪽 하단 모서리에 너비와 높이를 더한 값을 취하므로 이는 베이스라인에서 위로 자라는 막대와 정확히 일치합니다. 채우기(fill) 색상을 먼저 설정하고 경로를 놓은 다음 Fill을 호출하여 칠하세요. 카테고리 레이블은 베이스라인 아래에, 값은 막대 위에 둡니다:

procedure DrawBarChart(Page: THPDFPage; const Plot: TPlotArea;
  const Values: array of Single; const Labels: array of string);
var
  I, Count: Integer;
  SlotW, BarW, BarX, BarH, Gap: Single;
begin
  Count := Length(Values);
  SlotW := Plot.Width / Count;
  Gap := SlotW * 0.25;          // quarter-slot gap on each side
  BarW := SlotW - Gap;

  // Baseline (the X axis) along the bottom of the plot.
  Page.SetLineWidth(1.0);
  Page.MoveTo(Plot.Left, Plot.Bottom);
  Page.LineTo(Plot.Left + Plot.Width, Plot.Bottom);
  Page.Stroke;

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

    Page.SetRGBFillColor(RGB(56, 110, 219));
    Page.Rectangle(BarX, Plot.Bottom, BarW, BarH);  // X, Y, Width, Height
    Page.Fill;

    // Category label below the baseline, value above the bar.
    Page.SetRGBFillColor(clBlack);
    Page.TextOut(BarX, Plot.Bottom - 14, 0, Labels[I]);
    Page.TextOut(BarX, Plot.Bottom + BarH + 4, 0,
      FormatFloat('0', Values[I]));
  end;
end;

두 가지 세부 사항이 제 몫을 합니다. Gap은 고정된 포인트 수가 아니라 슬롯의 분수 형태이므로 4개의 카테고리를 표시하든 40개를 표시하든 막대의 비율이 유지됩니다. 그리고 값 레이블은 막대가 사용하는 것과 동일한 ValueToY에서 파생된 높이로 배치되므로, 임의의 간격에서 떠다니지 않고 항상 자체 막대 바로 위에 위치합니다. 막대 뒤에 가로 눈금선(horizontal gridlines)을 원한다면 루프 전에 그리세요: 세 개나 네 개의 적절한 둥근 숫자를 선택하고 각각에 대해 ValueToY를 실행한 다음 해당 Y 위치에서 플롯을 가로질러 옅은 선을 그으세요. PDF가 사용하는 화가 알고리즘(painter's-model) 누적 방식에서는 선을 먼저 그리는 것이 막대 뒤에 두는 것을 의미합니다

축, 눈금(tick) 표시, 그리고 레이블도 그저 선과 텍스트일 뿐입니다

독자가 막대의 의미를 알 수 있을 때까지 차트는 완성된 것이 아니며, 그것은 온전히 축(axis) 작업입니다. 수직 축은 플롯의 왼쪽 가장자리를 따라 그려진 하나의 테두리 선이며, 소수의 눈금 기호와 값들로 구성됩니다. 눈금이 막대와 동일한 배율에 도달하도록 ValueToY를 재사용하세요. 그렇지 않으면 막대와 눈금선이 일치하지 않고 차트가 은밀하게 거짓말을 하게 됩니다:

procedure DrawValueAxis(Page: THPDFPage; const Plot: TPlotArea;
  TickCount: Integer);
var
  I: Integer;
  TickV, TickY: Single;
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);   // short tick outside the axis
    Page.LineTo(Plot.Left, TickY);
    Page.Stroke;
    Page.TextOut(Plot.Left - 30, TickY - 3, 0, FormatFloat('0', TickV));
  end;
end;

프로덕션 환경에서 차트가 가장 자주 깨지는 곳이 레이블이며, 그 실패 원인은 항상 동일합니다: 화면에 맞던 텍스트가 PDF의 공간을 초과하는 것입니다. 긴 카테고리 이름은 이웃 이름과 충돌하며, "septembre" 또는 "Dezember"와 같이 지역화된 달 이름은 테스트에 사용했던 영어 "Sep"보다 더 넓습니다. 여기서 여러분을 구해줄 자동 크기 조정(autosize)은 없으므로 베이스라인 아래에 진짜 여백을 남겨 두고 카테고리 집합이 조밀한 경우 글꼴 크기를 1~2 포인트 줄이세요. 그리고 이름이 정말 길다면 그것들을 회전시키세요. TextOut은 각도를 세 번째 인수로 취하므로 90을 전달하면 레이블을 세로로 세워 겹침 없이 공간을 확보할 수 있습니다. 릴리스하기 전에 가장 짧은 레이블이 아닌 예상되는 가장 넓은 레이블로 레이아웃을 테스트하세요

꺾은선형 차트: 매핑된 점을 지나는 하나의 폴리라인

꺾은선형 차트는 값 매핑 전체를 재사용하고 점이 연결되는 방식만 변경합니다. 카테고리당 사각형이 아니라 데이터를 한 번 살펴보면서 각 값을 ValueToY로 (X, Y)로 변환하고, MoveTo와 그 뒤를 따르는 LineTo 호출로 점들을 하나의 경로에 이어붙인 다음 마지막에 테두리(stroke)를 그립니다. 첫 번째 점이 경로를 열고; 이후의 모든 점은 그 경로를 연장합니다:

procedure DrawLineChart(Page: THPDFPage; const Plot: TPlotArea;
  const Values: array of Single);
var
  I, Count: Integer;
  StepX, X, Y: Single;
begin
  Count := Length(Values);
  if Count < 2 then Exit;
  StepX := Plot.Width / (Count - 1);

  Page.SetLineWidth(1.5);
  Page.SetRGBStrokeColor(RGB(214, 92, 36));
  for I := 0 to Count - 1 do
  begin
    X := Plot.Left + I * StepX;
    Y := ValueToY(Plot, Values[I]);
    if I = 0 then
      Page.MoveTo(X, Y)        // open the path at the first point
    else
      Page.LineTo(X, Y);       // extend it through every later point
  end;
  Page.Stroke;                 // one stroke paints the whole polyline
end;

간격 차이에 유의하세요. 막대 차트는 각 막대가 한 슬롯을 차지하므로 너비를 막대 수로 나눕니다. 꺾은선형 차트는 첫 번째 점과 마지막 점이 플롯 가장자리에 놓이고 선이 그 사이 간격을 연결하므로 간격 수인 Count - 1로 나눕니다. 이 두 가지를 혼동하는 것이 꺾은선형 차트가 그 위에 놓일 막대 차트에서 반 슬롯 벗어나는 일반적인 이유입니다. 각 데이터 지점에 마커를 원한다면 폴리라인 테두리를 그린 후에 매 (X, Y) 지점에 작은 CircleFill을 떨어뜨리세요

원형 차트: 호(arc), 또는 단순하게 할 거면 부채꼴(wedge)

원형 조각(pie slices)은 삼각법이 필요한 유일한 도형입니다. 부채꼴은 두 개의 반지름과 호로 둘러싸여 있기 때문입니다. 가장 정직한 버전은 작은 선 세그먼트를 원주를 따라 이동시켜 호(arc)를 스윕하는 것입니다. 이 방식은 너무 근접하게 커브에 가까워져 독자가 알아차릴 수 없습니다. 각 조각의 스윕 각도는 전체에서 차지하는 비율인 (Value / Total) * 2π이며 돌아가면서 누적 각도를 추가합니다:

procedure DrawPieChart(Page: THPDFPage; CX, CY, Radius: Single;
  const Values: array of Single; const Colors: array of TColor);
var
  I, Step, Steps: Integer;
  Total, Start, Sweep, A: Single;
begin
  Total := 0;
  for I := 0 to High(Values) do Total := Total + Values[I];
  Start := 0;

  for I := 0 to High(Values) do
  begin
    Sweep := (Values[I] / Total) * 2 * Pi;
    Steps := Round(Sweep / (Pi / 90)) + 1;  // ~2 degrees per segment

    Page.SetRGBFillColor(Colors[I]);
    Page.MoveTo(CX, CY);                     // wedge apex at the center
    for Step := 0 to Steps do
    begin
      A := Start + Sweep * (Step / Steps);
      Page.LineTo(CX + Radius * Cos(A), CY + Radius * Sin(A));
    end;
    Page.LineTo(CX, CY);                      // close back to the center
    Page.Fill;

    Start := Start + Sweep;                   // advance to the next slice
  end;
end;

중심 밖으로 나가는 경로, 꼭지점 먼저, 그 다음에 호, 그리고 다시 꼭지점으로 돌아오면 Fill이 견고하게 칠하는 닫힌 부채꼴을 줍니다. 세그먼트 수는 매끄러움과 경로 크기를 교환합니다: 약 2도씩 밟아가는 것은 엄청난 콘텐츠 스트림을 생성하지 않고도 어떤 합리적인 반지름에서도 둥글게 보입니다. 완벽한 원이 필요 없다면 삼각법을 완전히 건너뛰고 동일한 데이터를 단일 수평 누적 막대로 렌더링할 수 있으며, 각 세그먼트의 너비는 그 비율에 비례합니다. 이는 어쨌든 종종 파이보다 더 가독성이 좋으며, 사각형을 끝에서 끝까지 배열한 막대 코드일 뿐입니다. 디자인에서 진짜 원을 요구할 때만 호 버전을 선택하세요

이 모든 것은 설치된 차트 라이브러리에 의존하지 않습니다. 이것이 원시 요소를 직접 그릴 때의 조용한 이점입니다. 로고나 서명 상자를 배치하는 것과 동일한 캔버스 그리기 호출이 차트들을 구축하고, 폼 필드에 레이블을 지정하는 것과 동일한 TextOut이 축에 레이블을 지정합니다. 플롯 기하학을 레코드에 넣고 Y에 값을 한 번 매핑하면 막대, 꺾은선형 또는 원형 차트는 Rectangle, LineToCircle 위의 짧은 루틴이 되어 어떤 보고서에든 적용할 수 있습니다. 여기에 사용된 Rectangle, MoveTo, LineTo, Circle, Fill, StrokeTextOut 호출은 델파이 및 C++Builder용 HotPDF 컴포넌트의 일부입니다