양쪽 맞춤 정렬(Full justification)은 텍스트 열의 왼쪽과 오른쪽 가장자리를 모두 나란히 정렬하는 레이아웃으로, 인쇄된 책이나 공식 보고서에서 기대할 수 있는 모습입니다. 설명하기는 쉽지만 놀라울 정도로 잘못되기 쉬운데, "남는 공간이 어디로 가야 하는가"에 대한 대답이 영어와 일본어에 대해 서로 다르고, 각 줄을 측정하는 단순한 방법이 빠른 페이지를 느리게 만들기 때문입니다. HotPDF는 단일 상자 레이아웃 호출을 통해 스크립트를 인식하는 양쪽 맞춤을 제공하며, 이 호출 아래에는 그 자체로 이해할 가치가 있는 교과서적인 성능 수정 사항이 자리 잡고 있습니다
이 기사에서는 이 두 가지를 모두 살펴봅니다. 첫째, 단어 간격이 있는 스크립트와 없는 스크립트 사이에서 남는 공간(slack)이 어떻게 분배되는지 결정하는 타이포그래피 규칙입니다. 둘째, 출력에서 눈에 띄는 차이 없이 양쪽 맞춤의 페이지당 비용을 대략 80배 줄인 측정 변경 사항입니다. 대량으로 문서를 생성하고 문서가 단순히 크기에 맞춰 늘어난 고정폭 출력이 아니라 실제 조판처럼 읽히기를 원한다면 두 가지 모두 중요합니다
양쪽 맞춤 정렬에 실제로 필요한 것
원래 너비로 그려진 텍스트 줄은 열의 오른쪽 가장자리에 거의 닿지 않습니다. 마지막 글리프가 끝나는 곳과 열의 경계가 있는 곳 사이에는 항상 나머지, 즉 남는 공간이 있습니다. 왼쪽 정렬은 이 남는 공간을 오른쪽에 남겨 둡니다. 오른쪽 정렬은 이를 왼쪽으로 이동시킵니다. 가운데 정렬은 이를 나눕니다. 양쪽 맞춤은 양쪽 가장자리가 상자와 만날 때까지 줄 자체를 넓혀서 이를 제거하며, 이를 수행하는 유일한 정직한 방법은 내부에서 글리프를 서로 밀어내는 것입니다
좋은 양쪽 맞춤과 나쁜 양쪽 맞춤을 구분하는 규칙은 이 남는 공간을 어디에 두느냐에 있습니다. 영어 및 라틴어 계열과 같이 단어 사이에 공백을 두어 쓰는 스크립트는 모든 단어 간격에 자연스러운 이음새가 있습니다. 독자들은 이미 단어 간격이 다양하다는 것을 받아들이기 때문에 이 공간을 넓히는 것은 눈에 띄지 않습니다. 한자, 일본어 가나 또는 한국어 한글과 같이 단어 사이에 공백 없이 쓰는 스크립트에는 이러한 이음새가 없습니다. 이 경우 남는 공간은 인접한 글리프 사이에 고르게 분배되어야 하며, 이는 일본 식자공들이 균등 할당(kintou-waritsuke)이라고 부르는 원칙입니다. 라틴어 스타일의 단어 간격 늘리기를 CJK 줄에 적용하거나, CJK 줄이 우연히 포함하는 공간 한 곳에 모든 남는 공간을 밀어 넣으면 아마추어 출력임을 나타내는 강(rivers)과 간격이 생깁니다
HotPDF가 공간의 위치를 결정하는 방법
HotPDF는 이 결정을 줄 단위가 아니라 간격 단위로 내립니다. 줄을 양쪽 맞춤할 때 인접한 모든 글리프 쌍을 살펴보고 그 사이에 늘릴 수 있는 경계가 있는지 확인합니다. 어느 한쪽이 공백이나 탭인 경우(라틴어의 경우) 또는 양쪽이 모두 CJK 분할 가능 문자인 경우(균등 간격의 경우) 경계를 늘릴 수 있습니다. 이러한 경계의 수를 세고 줄의 남는 공간을 이들 사이에 균등하게 나눈 다음 자격을 갖춘 각 간격에 해당 몫을 추가합니다
그 결과는 자연스럽게 나타납니다. 영어 줄은 단어 공백에만 늘릴 수 있는 경계가 있으므로 모든 남는 공간이 거기에 배치되고, 각 단어 내부의 글자는 원래의 간격을 유지하면서 단어들이 서로 벌어집니다. 한자나 가나 줄은 거의 모든 글리프 쌍 사이에 늘릴 수 있는 경계가 있으므로 남는 공간이 줄 전체에 고르게 분배되어, 해당 스크립트가 요구하는 정확한 균등 글리프 간격이 됩니다. 내부에 공백이 없는 하나의 긴 라틴어 단어로 된 줄은 늘릴 수 있는 경계가 전혀 없으므로 HotPDF는 문자를 하나하나 분리하는 대신 원래의 너비대로 둡니다. 결정이 각 경계에 국한되기 때문에 특수한 경우 없이도 한 줄에 라틴어와 CJK가 혼합된 실행을 동일한 논리로 처리합니다
한 가지 경계는 어디에서나 의도적으로 제외됩니다. 줄의 마지막 글리프 다음 위치는 간격으로 취급되지 않는데, 여기서 늘리면 오른쪽 나머지가 다시 발생하여 양쪽 맞춤과 반대되는 결과를 낳기 때문입니다
마지막 줄이 그대로 유지되는 이유
단락의 마지막 줄은 특별하며, 이를 잘못 처리하는 것은 가장 일반적인 양쪽 맞춤 버그입니다. 단락의 마지막 줄은 보통 짧고 종종 몇 단어에 불과하므로, 이를 열 전체 너비로 늘리면 해당 단어들이 페이지를 가로질러 듬성듬성하고 끊어진 행으로 끌려갑니다. 올바른 타이포그래피는 마지막 줄을 원래 너비로 왼쪽으로 정렬된 상태로 둡니다
HotPDF는 위치를 기준으로 후행 줄을 감지합니다. 텍스트를 여러 줄로 줄 바꿈할 때 방금 분리한 줄이 제공된 문자열의 끝에 도달하는 시점을 압니다. 이 마지막 줄은 일반적인 왼쪽 정렬로 방출되며 원래 너비를 유지합니다. 그 이전의 모든 줄은 양쪽 가장자리에 맞춰 양쪽 맞춤됩니다. 텍스트에 쓴 하드 줄 바꿈은 쓰인 그대로 유지되므로 의도적인 짧은 줄 역시 절대 늘어나지 않습니다. 독자는 마지막 줄이 자연스럽게 끝나는 깨끗한 직사각형 텍스트 블록을 보게 되며, 이는 눈이 기대하는 바와 일치합니다
양쪽 맞춤을 느리게 만든 측정 비용
줄을 양쪽 맞춤하려면 줄의 정확한 너비와 각 글리프의 진행(advance) 값을 알아야 잉여 공간을 정확하게 배치할 수 있습니다. 첫 번째 구현은 명백한 방식으로 이 숫자들을 얻었습니다. 전체 유니코드 너비 쿼리로 전체 줄을 측정한 다음 접두어를 차례로 측정하여 차이를 통해 각 글리프의 진행 값을 복구했습니다. N개의 글리프로 이루어진 줄의 경우 이는 측정 엔진을 N+1번 호출하는 것이며, 각 호출은 운영 체제에 텍스트를 모양 내고 측정하여 답을 돌려달라고 요청하는 전체 GDI 왕복(round-trip)입니다
줄 단위로는 저렴하게 들리지만 페이지 전체를 놓고 보면 그렇지 않습니다. 본문 텍스트가 빽빽한 A4 페이지를 생각해 보겠습니다. 대략 80자로 이루어진 45개의 줄이 있습니다. 줄당 N+1회의 왕복의 경우, 각 줄마다 약 81회, 페이지 전체로는 약 3,645회의 왕복이 발생하며, 이들 중 거의 대부분은 엔진이 방금 전 살펴본 텍스트를 다시 측정하는 데 소요됩니다. 수천 페이지를 생성하는 일괄 처리 작업에서 이 오버헤드는 레이아웃 시간을 지배하며, 모든 왕복은 프로세스와 그래픽 하위 시스템 간의 경계를 넘나듭니다
N + 1번 대신 한 번의 호출
이 수정 사항은 작아 보이지만 큰 효과를 내는 종류의 변화입니다. GDI는 이미 단일 쿼리에서 문자열의 전체 너비와 모든 글리프의 위치를 보고할 수 있습니다. HotPDF는 GetWideCharAdvances를 통해 이를 노출하며, 이 함수는 커닝이 포함된 각 글리프의 고유한 진행 값으로 배열을 채우고 N+1번이 아닌 단 한 번의 호출로 전체 너비를 반환합니다. 양쪽 맞춤 루틴(내부적으로 _HPDFEmitJustifiedWideLine)은 한 번에 모든 진행 값을 요청하고, 잉여 공간을 계산하여 늘어날 수 있는 경계 전체에 분배한 다음 줄을 방출합니다
동일한 A4 페이지의 경우 줄당 측정 횟수가 약 81회 왕복에서 1회로 떨어지므로 페이지 전체로는 약 3,645회 왕복에서 약 45회로 감소하여 거의 80배의 감소 효과가 나타납니다. 측정이 요청된 횟수 외에는 측정에 대해 아무것도 변경되지 않았기 때문에 출력은 바이트 단위로 동일합니다. 동일한 GDI 엔진, 동일한 글꼴 메트릭, 동일한 커닝이 동일한 숫자를 제공합니다. 왕복 횟수만 감소했을 뿐입니다. 측정이 이미 정확할 때의 올바른 최적화는 측정을 근사화하는 것이 아니라 측정 요청을 반복적으로 멈추는 것입니다
줄이 페이지에 그려지는 방법
잉여 공간이 분배되면 HotPDF는 ExtTextOut과 글리프당 진행 배열인 Dx 배열을 사용하여 줄을 방출합니다. 각 항목은 한 글리프의 시작점에서 다음 글리프까지의 거리이며, 이는 해당 글리프의 고유 진행 값에 뒤에 늘어날 수 있는 경계가 올 때 잉여 공간의 몫을 더한 것입니다. 이는 PDF 이미징 모델에 직접 매핑됩니다. 배치된 텍스트는 글리프 실행을 명시적인 가로 조정과 번갈아 나열하는 배열인 TJ 연산자로 작성되며, Dx 값이 정확히 그 조정이 됩니다. 이것이 추가 공간이 패딩 문자로 가짜로 만들어지지 않고 정확한 하위 포인트 위치에서 글리프 사이에 놓이는 이유이며, 양쪽 맞춤된 HotPDF 줄이 다운스트림 도구가 다시 읽을 때 올바르게 측정되는 이유입니다
양쪽 맞춤된 단락에 대해 ExtTextOut을 직접 호출하지는 않습니다. 진입점은 유니코드 문자열을 상자로 감싸고 요청한 정렬을 적용하는 WideTextOutBox입니다. 이 함수는 텍스트를 상자 너비에 맞는 줄로 분할하고 각 줄을 상자 높이 아래로 배치하며 수직 공간이 부족해지기 전에 맞출 수 있었던 문자 수를 반환합니다. 정렬은 양쪽 맞춤 열거형으로 선택됩니다
type
THPDFJustificationType = (jtLeft, jtCenter, jtRight, jtJustify);
처음 세 개는 자명하게 각각 왼쪽, 가운데, 오른쪽 정렬입니다. 네 번째인 jtJustify는 여기서 설명한 전체 양쪽 맞춤이며, 스크립트 인식 간격을 켜기 위해 WideTextOutBox가 읽는 값입니다
실제로 단락 양쪽 맞춤하기
전체 예제는 문서를 만들고, 글꼴을 설정하고, 단락을 전체 양쪽 맞춤이 적용된 상자에 쏟아 붓습니다. 스크립트 인식이 API 아래에 존재하기 때문에 동일한 코드가 플래그 변경 없이 라틴어 및 CJK 텍스트를 모두 양쪽 맞춤합니다
uses
HPDFDoc;
procedure JustifyParagraph;
var
Pdf: THotPDF;
Body: WideString;
begin
Pdf := THotPDF.Create(nil);
try
Pdf.FileName := 'Justified.pdf';
Pdf.BeginDoc;
Pdf.CurrentPage.SetFont('Arial', 11);
Body :=
'Full justification spreads the slack on each filled line so both ' +
'edges meet the column, while the last line keeps its natural width. ' +
'For scripts with word gaps the space lands between words; for ' +
'scripts without them it spreads evenly between glyphs.';
// X, Y, LineSpacing, BoxWidth, BoxHeight, Text, Align
Pdf.CurrentPage.WideTextOutBox(72, 72, 4, 380, 240, Body, jtJustify);
Pdf.EndDoc;
finally
Pdf.Free;
end;
end;
동일한 블록을 왼쪽 정렬, 가운데 정렬 또는 오른쪽 정렬로 그리려면 마지막 인수만 jtLeft, jtCenter 또는 jtRight로 변경하면 됩니다. 줄 바꿈, 줄 배치 및 반환 값은 동일하게 유지됩니다. 이 네 경로를 모두 구동하는 측정된 너비는 GetWideTextWidth에서 가져옵니다. 이는 유니코드 인식 너비 쿼리로, 구형 바이트 단위 측정이 Latin-1을 넘어선 모든 것의 크기를 잘못 계산하는 것과 달리 WideString을 올바르게 측정하며, 이를 통해 상자가 처음부터 올바른 위치에서 CJK 및 서로게이트 쌍 텍스트를 줄 바꿈할 수 있게 합니다
양쪽 맞춤은 더 큰 텍스트 모양 결정(text-shaping) 스택의 한 계층입니다. 줄에 글리프를 재정렬하거나 결합하는 스크립트가 포함된 경우 이곳의 간격 결정은 복합 스크립트 텍스트 셰이핑에 관한 기사에 설명된 작업 위에 위치하며, 글꼴이 선택하고 싶은 타이포그래피 변형을 가지고 있는 경우 OpenType GSUB 문체 대체(stylistic alternates)를 구동하는 방법을 참조하세요. 이 모든 기능은 이 블로그의 다른 곳에서 다루는 더 넓은 텍스트, 레이아웃 및 문서 API와 함께 Delphi 및 C++Builder용 HotPDF Component의 일부로 제공됩니다