PDF 페이지에 텍스트를 배치하는 호출은 간단합니다. AddText에 문자열, 폰트, 크기 및 위치를 지정하면 글리프(glyphs)가 나타납니다. 하지만 텍스트가 그려졌을 때 해당 문자열의 너비가 얼마가 될지 알려주지 않으며 긴 문자열을 여러 줄에 걸쳐 나누어 주지도 않습니다. 단일 호출은 한 위치에 한 줄의 텍스트를 그립니다. 그려진 문자열이 원래 의도했던 열(column)보다 넓으면 그냥 가장자리를 지나가 버리며 그리기 호출의 그 무엇도 여러분에게 경고를 주지 않습니다. 단일 레이블이 아닌 단락을 원할 때, 놓치고 있는 조각은 선택한 폰트와 크기의 문자열 너비를 페이지에 커밋하기 전에 측정하는 것입니다
이것은 전형적인 레이아웃 문제입니다. 단락을 열 안으로 래핑하려면 단어별로 각각의 후보 라인이 차지할 수평 공간의 양을 파악해야 하며, 무엇을 그리기 전부터 이를 알아야만 합니다. 자동 줄 바꿈(word wrap)은 그리기 호출 주위를 감싸는 측정 루프이며 그리기만 하는 바인딩은 여러분에게 나머지 절반만을 줍니다. PDFium 컴포넌트의 텍스트 측정 지원 기능은 페이지에 어떠한 표시도 남기지 않고 문자열의 렌더링된 너비 및 범위를 알려주는 MeasureText와 MeasureTextWidth, 두 함수를 통해 그 빈틈을 메워줍니다
측정 기능이 TPdf의 새 메서드가 아닌 클래스 헬퍼(class helper)인 이유
측정 지원은 TPdf 클래스에 결합된 새로운 메서드가 아니라, 독립된 자체 유닛 내에 위치한 TPdf를 위한 델파이 클래스 헬퍼로 제공됩니다. 클래스 헬퍼는 선언 외부에서 기존 타입에 메서드를 붙일 수 있도록 해주는 언어 기능입니다. 일단 유닛이 스코프(scope) 내로 들어오면, 이 새로운 메서드들은 마치 해당 클래스에 원래부터 속해 있던 것처럼 정확하게 호출되므로 별도로 구성하거나 주고받을 객체 없이 헬퍼 메서드가 Pdf.MeasureTextWidth(...)로 읽힙니다
이런 방식으로 계층을 두는 이유는 분리를 위해서입니다. TPdf의 핵심 타입은 어떠한 필드 추가나 기존 시그니처 건드림 없이 그대로 유지되므로 레이아웃이 전혀 필요하지 않은 프로젝트에는 텍스트 측정 코드가 들어가지 않습니다. 이 기능이 필요한 프로젝트는 uses 절에 유닛 하나를 더하는 것만으로 해당 메서드들을 쓸 수 있게 됩니다. 이런 기능적 확장은 개별 유닛 단위의 옵트인(opt-in) 방식으로 이뤄지는데, 이는 소유하고 있지 않거나 훼손하고 싶지 않은 타입을 확장하기에 가장 깔끔한 방법입니다
uses
PDFium, FPdfView, FPdfEdit,
FPdfMeasure; // the helper unit; brings MeasureText into scope on TPdf
// With the unit in scope the methods read as members of TPdf:
var
W, H: Double;
begin
Pdf.MeasureText('Subtotal', 'Helvetica', 11, W, H);
// W and H are now the rendered width and height in PDF user units
end;
페이지를 건드리지 않고 측정하기
측정 과정은 어떠한 부작용(side effect)도 없어야 합니다. 레이아웃을 결정하는 동안 이 기능을 수도 없이 호출하게 되므로 측정은 어떠한 흔적도 남기지 않고 너비를 반환해야 하며, 페이지는 애초에 측정 같은 것은 전혀 하지 않은 것처럼 정확히 유지되어야 합니다. 이를 가능케 하는 테크닉은 텍스트 객체를 만들고 크기를 물어본 다음 그것이 페이지에 부착되기도 전에 던져버리는(throw away) 것입니다
이 시퀀스(sequence)는 네 번의 PDFium 호출로 이루어집니다. FPDFPageObj_NewTextObj는 폰트 이름과 크기가 주어지면 문서를 상대로 한 텍스트 객체를 만듭니다. FPDFText_SetText는 객체가 가지고 있는 문자열을 설정합니다. FPDFPageObj_GetBounds는 객체의 경계 상자(bounding box)를 읽어옵니다. FPDFPageObj_Destroy는 객체를 해제합니다. 중요한 것은 이 시퀀스 내의 그 어떤 것도 페이지 삽입 API를 호출하지 않는다는 점입니다. 이 객체는 문서와 격리된 채로 만들어지고, 조회되며, 파괴되므로, 함수가 값을 반환할 때 문서에는 아무런 변화가 없습니다. 이것은 네 가지의 바운딩 박스 숫자를 출력으로 반환하는 쓰고 버리는(throwaway) 조사(probe) 과정일 뿐입니다
PDFium은 사용자가 직접 계산할 수 있도록 글자별 전진 너비(advance width)를 편하게 공개하지 않기 때문에, 이것이 작업을 수행하는 가장 견고한 방법입니다. 글리프의 폭 치수(metrics)는 폰트 프로그램과 인코딩, PDFium이 폰트 굵기를 가져오는(load) 방식에 따라 결정되며 한 문자열 내 각 문자의 전진 값을 알아낼 수 있는 공개적인 호출 방법이 없습니다. 반면 실제 텍스트 객체의 바운딩 박스는 그리기 위한 글리프를 배치(lay out)하는 것과 동일한 구조로 계산되므로 근삿값이 아닌 실제 렌더링된 크기를 반영합니다. 쓰레기성(disposable) 객체를 하나 만들고 바운딩 박스를 읽어 들이는 것이 라이브러리가 내놓을 수 있는 가장 신뢰할 만한 측정 방법입니다
// The shape of MeasureText, expressed against the verified PDFium calls.
// A text object is built, measured, and destroyed; no page is involved.
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); // probe discarded, page untouched
end;
end;
결과의 좌표와 단위
바운딩 박스는 왼쪽, 아래쪽, 오른쪽, 위쪽의 네 모서리로 돌아오며 두 개의 치수는 빼기에 의해 떨어져 나옵니다. 너비는 오른쪽에서 왼쪽을 뺀 것이고 높이는 위쪽에서 아래쪽을 뺀 것입니다. 둘 다 PDF 사용자 단위로 표현되며 1단위는 1인치의 1/72로 페이지에서 텍스트를 배치하는 것과 동일한 좌표 공간입니다. 이 단계에서는 숨겨진 기기 단위가 없으며 픽셀도 개입하지 않습니다. 36의 너비는 최종 렌더링 해상도와 무관하게 페이지의 절반 인치를 의미합니다
세로축은 PDF가 정의하는 방식에 따라 Y가 위로 갈수록 커지는 구조로 되어 있어서 높이는 거꾸로가 아니라 위쪽에서 아래쪽을 뺀 값으로 구합니다. 이 디테일은 열을 따라 커서를 밑으로 내릴 때 중요해집니다. 현재 베이스라인(baseline)에서 측정한 선의 높이를 뺀 위치가 다음 베이스라인의 위치가 되는데, 그 이유는 페이지 아래로 이동한다는 것은 더 작은 Y값을 향해 간다는 뜻이기 때문입니다. 대상이 종이가 아니라 화면이라면 디스플레이 해상도를 사용하여 사용자 단위를 기기 픽셀로 변환해야 합니다. 즉, 사용자 단위에 DPI를 곱하고 72로 나눈 값이 픽셀이 되므로 어디서 줄 바꿈을 할지 결정하기 전에 포인트 단위로 설정한 열 너비를 측정한 결과와 일치시킬 수 있습니다
변질된 입력에서 일어나는 일
함수들은 조용히 실패하도록 작성되어 있습니다. 열려있는 문서가 없거나 텍스트 객체를 생성할 수 없는 경우, 예외를 발생시키는 대신 너비가 0(zero)인 값을 반환합니다. 너비와 높이는 최상단에서 0으로 초기화되고 바운딩 박스를 성공적으로 다시 읽어 왔을 때만 덮어씁니다. 빈 문자열, 누락된 문서, 라이브러리가 객체로 분석(resolve)할 수 없는 글꼴 등, 각각의 경우에는 에러를 던지는(throwing) 대신 0을 반환합니다
수천 단어를 도는 반복(loop)은 매 반복마다 예외 처리를 할 곳이 아니기 때문에, 이러한 선택은 측정 루프를 단순하게 유지해 줍니다. 대신 확인(check)은 호출자(caller)가 감당해야 합니다. 0이라는 너비는 텍스트에 대한 사실이 아닌 지표(sentinel)이므로, 측정된 너비로 무언가를 나누거나 긍정적인 값으로 가정하는 코드는 그 값을 신뢰하기 전에 0인 경우에 대비하여 가드를 세워야 합니다. 0을 "측정할 수 없었음"으로 다루면 그 계약은 분명해집니다. 이를 무시하면 유효하지 않은(degenerate) 입력은 글리프들이 겹쳐진 채 이뤄진 하나의 열을 가지는 레이아웃이 되어버릴 것입니다
측정 기반 탐욕적 자동 줄 바꿈 알고리즘
너비 함수를 다루게 되면, 자동 줄 바꿈은 짧은 탐욕(greedy) 루프가 됩니다. 단락을 단어로 분할하고, 현재 줄을 유지한 채 해당 단어를 추가했을 때 줄이 어떻게 될지 단어별로 측정합니다. 시험용 줄(trial line)이 여전히 열 너비 안에 들어맞는다면 계속 추가하고, 흘러넘치게 되면 AddText로 현재 줄을 플러시(flush)하고 들어맞지 않았던 단어로 새 줄을 시작합니다. 누적(accumulation)은 전적으로 MeasureTextWidth를 통해 완료되며 실제로 페이지에 그려지는 건 당신이 이미 딱 맞을 거라고 확신한 줄뿐입니다
procedure WrapParagraph(Pdf: TPdf; const Para, Font: WString;
FontSize: Single; X, TopY, ColumnWidth, LineHeight: Double);
var
Words: TArray<string>;
Line, Trial: WideString;
I: Integer;
Y: Double;
begin
Words := string(Para).Split([' ']);
Line := '';
Y := TopY;
for I := 0 to High(Words) do
begin
if Line = '' then
Trial := Words[I]
else
Trial := Line + ' ' + Words[I];
// Measure the candidate line before drawing anything.
if (Line <> '') and (Pdf.MeasureTextWidth(Trial, Font, FontSize) > ColumnWidth) then
begin
Pdf.AddText(Line, Font, FontSize, X, Y); // flush the line that fit
Y := Y - LineHeight; // Y decreases going down
Line := Words[I]; // overflowing word starts next line
end
else
Line := Trial;
end;
if Line <> '' then
Pdf.AddText(Line, Font, FontSize, X, Y); // flush the final line
end;
선 너비는 단어 너비의 합산과 일치하지 않기 때문에 루프는 각 단어를 측정해 더하는 대신 매번 전체 시험용 줄을 측정합니다. 띄어쓰기로 인해 발생하는 여백이 그 차이를 만들고 묶음 측정이 이를 직접 포착합니다. 열(column)이 허용하는 한 꽉꽉 채워 넣고 간신히 걸쳐 맞는 마지막 순간에 줄 바꿈 하는 이 탐욕 규칙(greedy rule)은 일반 AddText와 실제 문단 사이 갭을 메워주는 룰과 동일합니다. 그리기 명령 자체는 절대 어려운 부분이었던 적이 없습니다. 그리기에 앞서 선행되어야 하는 측정이 어려웠던 것이며 바로 그것이 헬퍼가 제공해 주는 기능입니다
이것이 적합한 곳
측정은 콘텐츠 생성과 렌더링 사이의 계층(layer)이므로, 문서 생성 과정의 나머지 부분과 자연스럽게 맞물립니다. 애초부터 페이지를 조립하고 텍스트를 배치하는 경우, 기본 작업은 AddText와 페이지 설정이 자세하게 나와 있는 델파이에서 PDFium 컴포넌트를 사용하여 처음부터 PDF 문서 생성하기에 있습니다. 측정하고 있는 폰트 정보가 문자열만큼 중요한 경우(지표가 서체에 따라 달라지기 때문), 델파이에서 PDFium 컴포넌트를 사용하여 PDF 폰트 속성 분석하기에서 해당 바운딩 박스를 작동시키는 폰트 정보를 라이브러리가 어떻게 보고하는지 보여줍니다. 두 작업 모두 델파이 및 Lazarus용 PDFium 컴포넌트라는 동일한 바인딩을 토대로 빌드되며 측정 헬퍼는 이 블로그 전체에 설명되어 있는 문서, 페이지, 텍스트 API와 함께 배포됩니다