기술 기사

Delphi에서 레이아웃 및 단어 줄바꿈을 위한 PDF 텍스트 측정

PDF 페이지에 텍스트를 넣는 호출은 간단명료합니다. AddText에 문자열, 글꼴, 크기 및 위치를 제공하면 글리프(glyphs)가 나타납니다. 하지만 이 함수가 해주지 않는 것은 그 문자열이 그려졌을 때 너비가 얼마나 될지 알려주지 않는 것이며, 긴 문자열을 여러 줄로 나누어 주지도 않습니다. 단일 호출은 한 위치에 한 묶음의 텍스트를 그릴 뿐입니다. 만약 그 텍스트 묶음이 의도했던 단락의 너비보다 넓다면 그냥 가장자리를 넘어버리며, 그리기 호출의 어떤 부분도 여러분에게 경고하지 않습니다. 단일 레이블이 아닌 단락(paragraph)을 원하게 되는 순간, 페이지에 내용을 쓰기로 결정하기 전에 선택한 글꼴과 크기로 문자열의 너비를 미리 측정하는 빠진 조각이 필요해집니다

이것은 전형적인 레이아웃 문제입니다. 단락을 열(column)에 맞춰 줄바꿈하려면 단어 단위로 후보 줄이 가로 공간을 얼마나 차지할지 알아야 하며, 이를 그리기 전에 앞서 알아야 합니다. 단어 줄바꿈(word wrap)은 그리기 호출을 감싸고 있는 측정 루프(measurement loop)이며, 그리기만 수행하는 바인딩은 여러분에게 절반의 기능만 제공합니다. PDFium Component의 텍스트 측정 지원은 MeasureTextMeasureTextWidth라는 두 가지 함수로 그 간격을 메우며, 이 함수들은 어느 페이지에도 흔적을 남기지 않고 문자열의 렌더링 범위를 보고합니다

측정 기능이 TPdf의 새 메서드가 아닌 클래스 헬퍼인 이유

측정 지원 기능은 TPdf 클래스에 내장된 새 메서드가 아니라, 자체 유닛에 존재하는 TPdf를 위한 Delphi 클래스 헬퍼(class helper) 형태로 제공됩니다. 클래스 헬퍼는 기존 타입의 선언부 외부에서 해당 타입에 메서드를 붙일 수 있게 해주는 언어 기능입니다. 해당 유닛이 스코프(scope) 내에 들어오면, 새 메서드들은 마치 원래 클래스에 속해 있던 것처럼 정확히 호출되므로, 헬퍼 메서드는 별도의 객체를 생성하거나 전달할 필요 없이 Pdf.MeasureTextWidth(...)와 같이 읽힙니다

이 기능을 이런 방식으로 계층화한 이유는 분리(separation)를 위해서입니다. 핵심 TPdf 타입은 필드가 추가되거나 기존 서명이 건드려지지 않은 채 그대로 유지되므로, 레이아웃 기능이 전혀 필요 없는 프로젝트는 측정 코드를 품지 않게 됩니다. 이 기능이 필요한 프로젝트에서는 uses 절에 유닛 하나를 추가하면 해당 메서드들이 활성화됩니다. 내가 소유하지 않거나 건드리고 싶지 않은 타입을 확장하는 가장 깔끔한 방법은 단일 유닛의 세분성(granularity) 수준에서 기능을 선택적으로 도입(opt-in)하게 하는 것입니다

uses
  PDFium, FPdfView, FPdfEdit,
  FPdfMeasure;   // 헬퍼 유닛; TPdf의 스코프에 MeasureText를 가져옵니다

// 해당 유닛이 스코프 내에 있으면, 이 메서드들은 TPdf의 멤버로 읽힙니다:
var
  W, H: Double;
begin
  Pdf.MeasureText('Subtotal', 'Helvetica', 11, W, H);
  // 이제 W와 H는 PDF 사용자 단위(user units) 기준의 렌더링된 너비와 높이입니다
end;

페이지를 건드리지 않고 측정하기

측정은 부작용(side effects)이 없어야 합니다. 레이아웃을 결정하는 동안 수없이 많이 호출되기 때문에 흔적을 남기지 않고 너비를 보고해야 하며, 페이지는 애초에 측정 같은 것은 하지도 않았던 것처럼 원래의 모습을 유지해야 합니다. 이를 가능하게 만드는 기술은 텍스트 객체를 생성하여 그것의 크기를 묻고, 페이지에 실제로 첨부되기 전에 이를 버리는 것입니다

이 과정은 4번의 PDFium 호출로 이루어집니다. FPDFPageObj_NewTextObj는 글꼴 이름과 크기를 받아 문서를 상대로 텍스트 객체를 생성합니다. FPDFText_SetText는 그 객체가 지닐 문자열을 설정합니다. FPDFPageObj_GetBounds는 객체의 경계 상자(bounding box)를 읽어옵니다. FPDFPageObj_Destroy는 객체를 해제합니다. 결정적으로, 이 시퀀스 중 어떤 것도 페이지 삽입 API를 호출하지 않습니다. 객체는 격리된 상태에서 생성되고 질의를 받으며 파괴되므로, 함수가 반환될 때 문서는 변함이 없습니다. 이것은 경계 상자의 네 가지 숫자만을 유일한 출력으로 내놓는 일회용 탐침(throwaway probe)입니다

이 방법이 견고한 이유는, 스스로 합산할 수 있을 만한 편리한 글리프당 진행 너비(per-glyph advance width)를 PDFium이 공개하지 않기 때문입니다. 글리프 메트릭은 폰트 프로그램, 인코딩, 그리고 PDFium이 활자면(face)을 어떻게 로드하느냐에 따라 달라지며, 문자열 내 각 문자의 진행 값을 돌려주는 공개된 호출은 없습니다. 반면에 실제 텍스트 객체의 경계 상자는 그리기를 위해 글리프를 배치할 때 사용하는 바로 그 매커니즘에 의해 계산되므로, 근사치가 아닌 실제 렌더링 범위를 반영합니다. 하나의 일회용 객체를 만들고 그 경계를 읽어내는 것이 라이브러리가 줄 수 있는 가장 신뢰할 만한 측정법입니다

// 검증된 PDFium 호출에 대해 표현된 MeasureText의 형태.
// 텍스트 객체가 만들어지고 측정되고 파괴됩니다; 페이지는 관여하지 않습니다.
procedure TPdfMeasureHelper.MeasureText(const Text, Font: WString;
  FontSize: Single; out Width, Height: Double);
var
  TextObject: FPDF_PAGEOBJECT;
  L, B, R, T: Single;
begin
  Width  := 0;
  Height := 0;
  if Self.Document = nil then
    Exit;
  TextObject := FPDFPageObj_NewTextObj(Self.Document,
    FPDF_BYTESTRING(AnsiString(Font)), FontSize);
  if TextObject = nil then
    Exit;
  try
    if FPDFText_SetText(TextObject, FPDF_WIDESTRING(WideString(Text))) = 0 then
      Exit;
    if FPDFPageObj_GetBounds(TextObject, L, B, R, T) <> 0 then
    begin
      Width  := R - L;
      Height := T - B;
    end;
  finally
    FPDFPageObj_Destroy(TextObject);   // 탐침은 버려지고, 페이지는 건드려지지 않습니다
  end;
end;

결과의 좌표와 단위

경계 상자는 왼쪽(left), 아래쪽(bottom), 오른쪽(right), 위쪽(top)의 네 모서리 값으로 돌아오며 두 가지 치수는 뺄셈을 통해 나옵니다. 너비는 오른쪽에서 왼쪽을 뺀 값이고 높이는 위쪽에서 아래쪽을 뺀 값입니다. 둘 다 PDF 사용자 단위(user units)로 표현되는데, 1단위는 1/72인치이며 이는 여러분이 페이지에 텍스트를 배치하는 좌표 공간과 같습니다. 이 단계에서는 숨겨진 장치 단위(device unit)나 픽셀이 관여하지 않습니다. 나중에 렌더링 해상도가 어떻게 되든 너비 36은 페이지의 0.5인치를 의미합니다

수직 축은 PDF가 정의한 대로 Y가 위로 갈수록 증가하는 방향으로 향하며, 이것이 높이가 아래쪽에서 위쪽을 뺀 것이 아니라 위쪽에서 아래쪽을 뺀 값인 이유입니다. 이 세부 사항은 커서를 열(column) 아래로 이동시킬 때 중요해집니다. 줄의 높이를 측정한 다음, 페이지 아래로 이동한다는 것은 더 작은 Y를 향해 이동한다는 뜻이므로 현재의 기준선(baseline)에서 이 값을 빼서 다음 기준선을 찾아야 합니다. 만약 대상이 종이가 아니라 화면이라면 디스플레이 해상도를 사용하여 사용자 단위를 기기 픽셀로 변환합니다. 사용자 단위의 값에 DPI를 곱하고 72로 나누면 픽셀이 되므로, 포인트(points) 단위로 설정한 열 너비를 측정한 텍스트 너비와 비교하여 줄바꿈 위치를 결정할 수 있습니다

입력이 잘못된 경우 발생하는 일

이 함수들은 조용히 실패하도록 작성되었습니다. 열려 있는 문서가 없거나 텍스트 객체를 생성할 수 없는 경우, 예외가 발생하는 대신 0인 범위가 결과로 나옵니다. 너비와 높이는 맨 위에서 0으로 초기화되며, 경계 상자를 성공적으로 다시 읽어왔을 때만 덮어쓰입니다. 빈 문자열, 누락된 문서, 라이브러리가 객체로 해석할 수 없는 글꼴 등 각각의 경우 예외를 던지는 대신 0을 반환합니다

이러한 선택은 수천 개의 단어를 넘나드는 루프에서 매 반복마다 예외 처리를 할 자리가 없기 때문에 측정 루프를 단순하게 유지합니다. 그 대가로 호출자가 이 점검을 짊어지게 됩니다. 너비가 0이라는 것은 텍스트에 대한 사실이 아니라 예외를 알리는 신호(sentinel)이므로, 측정된 너비로 무언가를 나누거나 이 값을 양수라고 가정하는 코드는 이를 신뢰하기 전에 반드시 0인지부터 경계해야 합니다. 0을 "측정할 수 없었음"으로 취급하면 규칙이 명확해집니다. 이를 무시하면 잘못된 입력은 글리프들이 조용히 겹쳐진 열 형태의 레이아웃이 되고 맙니다

측정 위에 구축된 탐욕적 단어 줄바꿈 (Greedy word wrap)

너비 함수를 손에 넣었으니 단어 줄바꿈은 짧은 탐욕적(greedy) 루프가 됩니다. 단락을 단어 단위로 쪼개고, 현재 줄을 유지한 채 각 단어에 대해 그 단어를 추가했을 때의 줄 너비가 어떻게 될지 측정합니다. 시험적인 줄이 여전히 열의 너비에 들어맞는다면 계속 더하고, 넘쳐흐르게 되면 AddText로 현재 줄을 배출(flush)한 다음 들어맞지 않았던 단어로 새 줄을 시작합니다. 누적 작업은 전적으로 MeasureTextWidth에 의해서만 수행되며, 페이지에 도달하는 유일한 것은 이미 크기가 맞는다고 확인된 줄뿐입니다

procedure WrapParagraph(Pdf: TPdf; const Para, Font: WString;
  FontSize: Single; X, TopY, ColumnWidth, LineHeight: Double);
var
  Words: TArray<WideString>;
  Line, Trial: WideString;
  I: Integer;
  Y: Double;
begin
  Words := WideString(Para).Split([' ']);
  Line  := '';
  Y     := TopY;
  for I := 0 to High(Words) do
  begin
    if Line = '' then
      Trial := Words[I]
    else
      Trial := Line + ' ' + Words[I];
    // 무언가를 그리기 전에 후보 줄(candidate line)을 측정합니다.
    if (Line <> '') and (Pdf.MeasureTextWidth(Trial, Font, FontSize) > ColumnWidth) then
    begin
      Pdf.AddText(X, Y, Font, FontSize, Line);   // 잘 들어맞은 줄을 배출(flush)합니다
      Y    := Y - LineHeight;                    // Y는 아래로 갈수록 감소합니다
      Line := Words[I];                          // 넘쳐버린 단어로 다음 줄을 시작합니다
    end
    else
      Line := Trial;
  end;
  if Line <> '' then
    Pdf.AddText(X, Y, Font, FontSize, Line);      // 마지막 줄을 배출합니다
end;

이 루프는 각 단어를 일일이 측정하고 더하는 대신 시험적인 줄을 통째로 측정합니다. 왜냐하면 줄의 너비는 단순히 단어들의 너비 합산과 같지 않기 때문입니다. 단어 사이의 공백도 한몫을 차지하며, 합쳐진 한 묶음을 한 번에 측정하는 것이 이를 직접 포착해 냅니다. 열이 허용하는 한 최대한 많은 단어를 채우고 들어맞는 마지막 단어에서 끊어내는 이 탐욕적(greedy) 규칙은 순수 AddText와 진짜 단락 사이의 간격을 채우는 바로 그 규칙입니다. 그리기 호출은 애당초 어려운 부분이 아니었습니다. 그 전에 선행되어야만 하는 측정이 어려운 부분이었으며, 그것이 바로 이 헬퍼가 제공하는 것입니다

이 기능이 어울리는 곳

측정은 콘텐츠를 생성하는 것과 이를 렌더링하는 것 사이의 계층이므로, 바닥부터 시작하는 문서 워크플로의 나머지 부분과 자연스럽게 짝을 이룹니다. 만약 여러분이 첫 단계로 페이지를 구성하고 텍스트를 배치하고 있다면, 그 기초는 Delphi에서 PDFium Component로 바닥부터 PDF 문서 만들기에 있으며 여기에서 AddText와 페이지 설정에 대해 다룹니다. 글꼴 메트릭은 활자면(face)에 따라 달라지기 때문에 텍스트만큼이나 측정 중인 글꼴이 중요하다면, Delphi에서 PDFium Component로 PDF 글꼴 속성 분석하기에서 라이브러리가 경계 상자를 움직이는 글꼴 정보를 어떻게 보고하는지 보여줍니다. 두 내용 모두 Delphi와 Lazarus용 PDFium Component 바인딩을 기반으로 만들어졌으며, 측정 헬퍼는 이 블로그 전반에 걸쳐 설명된 문서, 페이지, 텍스트 API와 함께 제공됩니다